Community-websted og Discourse-forum
Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Successful in 1m1s
deploy hub / deploy (push) Successful in 19s
CI / MSI-installer (push) Successful in 36s

- src/SQLInsight.Community: offentligt websted med forside, funktioner, download
  af signerede udgivelser (med SHA-256), dokumentation fra docs/, SQL Server-
  versioner fra den nyeste signerede regelpakke, regler og supportaftaler.
  Viser de seneste forumemner. Streng CSP, ingen login, ingen database.
- deploy/forum: Discourse-opsætning (app.yml til discourse_docker, dansk
  sprog og søgning), install.sh og configure.py, der idempotent opsætter
  kategorier, løste svar, velkomstemne og indstillinger via API'et.
- deploy/community: Dockerfile og docker compose med Caddy til TLS.
- Webbrugerfladen linker til forum, dokumentation og support på webstedet
  (SQLInsight:Web:ForumUrl og CommunityUrl).
- Udgivelsesfiler hedder sqlinsight-<version>-<platform>.<ext>.
- Tests: sider, 404, signerede og manipulerede feeds, forum (også nede),
  dokumentationslinks og regler.
- docs/community.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 13:44:55 +00:00
parent 4fdeff87fa
commit be35e50335
No known key found for this signature in database
42 changed files with 1931 additions and 6 deletions

View file

@ -23,6 +23,7 @@
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
<PackageVersion Include="System.Diagnostics.EventLog" 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" />

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, 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. 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), [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).
---
@ -29,7 +29,7 @@ SQLInsight Pro vil gøre det anderledes:
| Pris | Gratis | Abonnement | Abonnement |
| Al overvågning og analyse | ✅ | ✅ | ✅ |
| Opdateringer af værktøj og regelpakker | ✅ | ✅ | ✅ |
| Community support (GitHub Discussions / forum) | ✅ | ✅ | ✅ |
| Community support ([forum](https://forum.sqlinsight.seosoft.dk/)) | ✅ | ✅ | ✅ |
| Prioriteret mailsupport med svartidsgaranti | – | ✅ | ✅ |
| Telefonsupport | – | – | ✅ |
| Findings-sync til SQLInsight Hub (proaktiv opfølgning) | – | Valgfrit | ✅ |
@ -207,6 +207,7 @@ Hvert **finding** indeholder:
- [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] 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)
## Understøttede versioner

View file

@ -3,6 +3,7 @@
<Project Path="src/SQLInsight.Agent/SQLInsight.Agent.csproj" />
<Project Path="src/SQLInsight.Collectors/SQLInsight.Collectors.csproj" />
<Project Path="src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj" />
<Project Path="src/SQLInsight.Community/SQLInsight.Community.csproj" />
<Project Path="src/SQLInsight.Contracts/SQLInsight.Contracts.csproj" />
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
@ -17,6 +18,7 @@
<Folder Name="/tests/">
<Project Path="tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj" />
<Project Path="tests/SQLInsight.Collectors.Tests/SQLInsight.Collectors.Tests.csproj" />
<Project Path="tests/SQLInsight.Community.Tests/SQLInsight.Community.Tests.csproj" />
<Project Path="tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj" />
<Project Path="tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj" />
<Project Path="tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj" />

View file

@ -0,0 +1,5 @@
{$COMMUNITY_HOST} {
encode zstd gzip
reverse_proxy community:8080
header Strict-Transport-Security "max-age=31536000"
}

View file

@ -0,0 +1,14 @@
# The SQLInsight Pro website. Build from the repository root:
# docker build -f deploy/community/Dockerfile -t sqlinsight-community .
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/SQLInsight.Community -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "SQLInsight.Community.dll"]

View file

@ -0,0 +1,39 @@
# The SQLInsight Pro website behind Caddy, which gets and renews the TLS certificate itself.
# DNS for COMMUNITY_HOST must point at this server, and ports 80 and 443 must be free (Discourse runs on its own server).
#
# COMMUNITY_HOST=sqlinsight.seosoft.dk docker compose up -d
services:
community:
build:
context: ../..
dockerfile: deploy/community/Dockerfile
image: sqlinsight-community
restart: unless-stopped
environment:
# The signed update feed: the hub's /updates/ (or a folder mounted into the container).
SQLInsight__Community__Feed: ${COMMUNITY_FEED:-https://hub.sqlinsight.seosoft.dk/updates/}
SQLInsight__Community__ForumUrl: ${COMMUNITY_FORUM:-https://forum.sqlinsight.seosoft.dk/}
SQLInsight__Community__ContactEmail: ${COMMUNITY_CONTACT:?set COMMUNITY_CONTACT (the address for support enquiries)}
# The public key releases are signed with, if it is not embedded in the build (the vendor tool's keygen prints it):
# SQLInsight__Community__TrustedKeys__k20260929: "MFkw..."
expose:
- "8080"
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
- community
environment:
COMMUNITY_HOST: ${COMMUNITY_HOST:?set COMMUNITY_HOST}
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:

68
deploy/forum/app.yml Normal file
View file

@ -0,0 +1,68 @@
## SQLInsight Pro community forum: Discourse, installed with the official discourse_docker launcher.
## See docs/community.md. Copy to /var/discourse/containers/app.yml, fill in the values marked CHANGE, and run
## ./launcher rebuild app
## (deploy/forum/install.sh does this for you.)
templates:
- "templates/postgres.template.yml"
- "templates/redis.template.yml"
- "templates/web.template.yml"
- "templates/web.ratelimited.template.yml"
## TLS with Let's Encrypt. Remove both lines if a reverse proxy in front already terminates TLS.
- "templates/web.ssl.template.yml"
- "templates/web.letsencrypt.ssl.template.yml"
expose:
- "80:80"
- "443:443"
params:
## Danish stemming for search.
db_default_text_search_config: "pg_catalog.danish"
## About 25 % of the server's memory.
db_shared_buffers: "512MB"
env:
LC_ALL: en_US.UTF-8
LANG: en_US.UTF-8
LANGUAGE: en_US.UTF-8
DISCOURSE_DEFAULT_LOCALE: da
## About 2 per CPU core.
UNICORN_WORKERS: 4
## CHANGE: the forum's address (a DNS record must point to this server).
DISCOURSE_HOSTNAME: forum.sqlinsight.seosoft.dk
## CHANGE: who becomes administrator when signing up with these addresses.
DISCOURSE_DEVELOPER_EMAILS: "admin@example.com"
## CHANGE: mail server. Discourse cannot work without it (sign-up, notifications, password reset).
DISCOURSE_SMTP_ADDRESS: smtp.example.com
DISCOURSE_SMTP_PORT: 587
DISCOURSE_SMTP_USER_NAME: forum@example.com
DISCOURSE_SMTP_PASSWORD: "CHANGE"
DISCOURSE_SMTP_ENABLE_START_TLS: true
DISCOURSE_NOTIFICATION_EMAIL: forum@example.com
## CHANGE: address Let's Encrypt writes to about expiring certificates.
LETSENCRYPT_ACCOUNT_EMAIL: admin@example.com
volumes:
- volume:
host: /var/discourse/shared/standalone
guest: /shared
- volume:
host: /var/discourse/shared/standalone/log/var-log
guest: /var/log
hooks:
after_code:
- exec:
cd: $home/plugins
cmd:
## Upgrades from the admin page.
- git clone https://github.com/discourse/docker_manager.git
run:
- exec: echo "Beginning of custom commands"
- exec: echo "End of custom commands"

148
deploy/forum/configure.py Executable file
View file

@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Sets up the SQLInsight Pro forum on a fresh Discourse through its API. Safe to run again: it only changes what
differs from the wanted state.
DISCOURSE_URL=https://forum.sqlinsight.seosoft.dk DISCOURSE_API_KEY=... python3 deploy/forum/configure.py
Create the key inside the container: ./launcher enter app then
cd /var/www/discourse && su discourse -c 'RAILS_ENV=production bundle exec rake api_key:create_master[forum-setup]'
Optional: COMMUNITY_URL (the community site, linked from the forum), CONTACT_EMAIL.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = os.environ.get("DISCOURSE_URL", "").rstrip("/")
KEY = os.environ.get("DISCOURSE_API_KEY", "")
COMMUNITY = os.environ.get("COMMUNITY_URL", "https://sqlinsight.seosoft.dk").rstrip("/")
CONTACT = os.environ.get("CONTACT_EMAIL", "")
SETTINGS = {
"title": "SQLInsight Pro Forum",
"site_description": "Spørgsmål, svar og erfaringer om SQL Server-overvågning med SQLInsight Pro.",
"short_site_description": "SQL Server-overvågning med dyb ekspertise",
"company_name": "SEOSoft",
"default_locale": "da",
"login_required": "false",
"solved_enabled": "true",
"tagging_enabled": "true",
"top_menu": "latest|categories|new|top",
"contact_url": f"{COMMUNITY}/support",
}
if CONTACT:
SETTINGS["contact_email"] = CONTACT
# name, slug, colour, description, accepted answers (solved), only staff may start topics
CATEGORIES = [
("Annonceringer", "annonceringer", "0E7490",
"Nye versioner, regelpakker og vigtige SQL Server-opdateringer. Kun SEOSoft opretter emner her; alle kan svare.", False, True),
("Spørgsmål og svar", "spoergsmaal", "155E75",
"Spørg om fund, anbefalinger og SQL Server generelt. Markér det svar, der løste det.", True, False),
("Installation og drift", "installation", "1570EF",
"MSI, rettigheder, repository-databasen, opdateringer, Linux og Managed Instance.", True, False),
("Performance og tuning", "performance", "E04F16",
"Wait stats, Query Store, planer, indexes og blocking. Del gerne målingerne fra fundet.", True, False),
("Regler", "regler", "079455",
"Forslag til nye regler og community-regler i SQL (se dokumentationen om egne regler).", False, False),
("Idéer og feedback", "ideer", "B54708",
"Hvad mangler SQLInsight Pro? Fejl oprettes som issues på Forgejo.", False, False),
]
WELCOME_TITLE = "Velkommen til SQLInsight Pro-forummet"
WELCOME = f"""SQLInsight Pro er gratis SQL Server-overvågning med dyb ekspertise: hvert fund har en forklaring, målingerne bag og en konkret anbefaling.
**Her kan du**
- stille spørgsmål om installation, fund og tuning i [Spørgsmål og svar](/c/spoergsmaal)
- dele erfaringer og foreslå nye [regler](/c/regler)
- følge nye versioner og regelpakker i [Annonceringer](/c/annonceringer)
**Gode råd til et hurtigt svar**
- Skriv SQL Server-version og CU (står på instansens side i SQLInsight Pro).
- Kopiér fundets titel, målinger og regel-id (fx `SQLI-DB-007`).
- Fjern servernavne, logins og forespørgselstekst, du ikke vil dele offentligt.
- Markér det svar, der løste problemet, så andre kan finde det.
Dokumentation og download: [{COMMUNITY.removeprefix('https://')}]({COMMUNITY}) · Betalt support med svartid: [{COMMUNITY.removeprefix('https://')}/support]({COMMUNITY}/support)
"""
def call(method, path, data=None):
body = urllib.parse.urlencode(data, doseq=True).encode() if data is not None else None
request = urllib.request.Request(BASE + path, data=body, method=method, headers={
"Api-Key": KEY, "Api-Username": "system", "Accept": "application/json",
})
for _ in range(10):
try:
with urllib.request.urlopen(request, timeout=30) as response:
text = response.read().decode()
return json.loads(text) if text else {}
except urllib.error.HTTPError as error:
body = error.read().decode()
if error.code == 429: # Discourse rate limit: wait as told, then try again
time.sleep(json.loads(body).get("extras", {}).get("wait_seconds", 10) + 1)
continue
sys.exit(f"{method} {path}: {error.code} {body[:300]}")
sys.exit(f"{method} {path}: stadig begrænset efter flere forsøg")
def main():
if not BASE or not KEY:
sys.exit(__doc__)
current = {s["setting"]: s.get("value") for s in call("GET", "/admin/site_settings.json")["site_settings"]}
for name, value in SETTINGS.items():
if name not in current:
print(f" (indstillingen {name} findes ikke i denne Discourse-version)")
elif str(current[name]) != value:
call("PUT", f"/admin/site_settings/{name}.json", {name: value})
print(f"indstilling {name} = {value}")
categories = {c["slug"]: c for c in call("GET", "/categories.json")["category_list"]["categories"]}
for name, slug, colour, description, solved, staff_only in CATEGORIES:
fields = {
"name": name, "slug": slug, "color": colour, "text_color": "FFFFFF",
"custom_fields[enable_accepted_answers]": "true" if solved else "false",
}
if staff_only:
fields.update({"permissions[everyone]": "2", "permissions[staff]": "1"}) # everyone: reply; staff: create
if slug in categories:
call("PUT", f"/categories/{categories[slug]['id']}.json", fields)
category_id = categories[slug]["id"]
print(f"kategori {name} (opdateret)")
else:
category_id = call("POST", "/categories.json", fields)["category"]["id"]
print(f"kategori {name} (oprettet)")
# The description is the first post of the category's "About" topic.
about = call("GET", f"/c/{category_id}/show.json")["category"].get("topic_url")
if about:
topic = call("GET", about + ".json")
post = topic["post_stream"]["posts"][0]
if post.get("raw") != description and description not in (post.get("cooked") or ""):
call("PUT", f"/posts/{post['id']}.json", {"post[raw]": description})
category_id = next(c["id"] for c in call("GET", "/categories.json")["category_list"]["categories"] if c["slug"] == "annonceringer")
announcements = call("GET", f"/c/annonceringer/{category_id}/l/latest.json")["topic_list"]["topics"]
if not any(t["title"] == WELCOME_TITLE for t in announcements):
topic = call("POST", "/posts.json", {"title": WELCOME_TITLE, "raw": WELCOME, "category": category_id})
call("PUT", f"/t/{topic['topic_id']}/status.json", {"status": "pinned_globally", "enabled": "true"})
print("emne velkomst (oprettet og fastgjort)")
# Discourse's own English welcome topic: no longer pinned or listed; ours replaces it.
for topic in call("GET", "/latest.json")["topic_list"]["topics"]:
if topic.get("pinned_globally") and topic["title"] != WELCOME_TITLE and topic["title"].startswith("Welcome to"):
call("PUT", f"/t/{topic['id']}/status.json", {"status": "pinned_globally", "enabled": "false"})
call("PUT", f"/t/{topic['id']}/status.json", {"status": "visible", "enabled": "false"})
print("emne Discourses engelske velkomst skjult")
feedback = next((c for c in call("GET", "/categories.json")["category_list"]["categories"] if c["slug"] == "site-feedback"), None)
if feedback and feedback["name"] != "Om forummet":
call("PUT", f"/categories/{feedback['id']}.json", {"name": "Om forummet", "slug": "site-feedback", "color": feedback["color"]})
print("kategori Site Feedback omdøbt til Om forummet")
print("Forummet er sat op.")
if __name__ == "__main__":
main()

46
deploy/forum/install.sh Executable file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Installs the SQLInsight Pro forum (Discourse) on a fresh Linux server with the official discourse_docker launcher.
# Run as root on the forum server, from a checkout of this repository, after filling in deploy/forum/app.yml:
# sudo deploy/forum/install.sh
# Needs: DNS for the forum's host name pointing here, ports 80 and 443 free, 2 GB memory (4 GB recommended) and a mail server.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
TARGET=/var/discourse
if [[ $EUID -ne 0 ]]; then
echo "Kør som root (sudo)." >&2
exit 1
fi
# Refuse to install with the example values still in place: Discourse is useless without a working mail server.
if grep -nE 'example\.com|"CHANGE"' "$HERE/app.yml" | grep -vE '^[0-9]+:\s*#'; then
echo "Udfyld værdierne markeret CHANGE i $HERE/app.yml først (linjerne ovenfor)." >&2
exit 1
fi
if ! command -v docker >/dev/null; then
echo "Installerer Docker ..."
curl -fsSL https://get.docker.com | sh
fi
if [[ ! -d $TARGET/.git ]]; then
git clone --depth 1 https://github.com/discourse/discourse_docker.git "$TARGET"
fi
install -m 600 "$HERE/app.yml" "$TARGET/containers/app.yml"
cd "$TARGET"
./launcher rebuild app
host=$(sed -n 's/^\s*DISCOURSE_HOSTNAME:\s*//p' "$HERE/app.yml" | tr -d '"' | head -1)
cat <<NEXT
Discourse kører. Næste skridt:
1. Åbn https://$host og opret den første administrator med en af adresserne i DISCOURSE_DEVELOPER_EMAILS.
2. Lav en API-nøgle til opsætningen:
cd $TARGET && ./launcher enter app
cd /var/www/discourse && su discourse -c 'RAILS_ENV=production bundle exec rake api_key:create_master[forum-setup]'
3. Opsæt kategorier, sprog og velkomstemne (kan køres igen):
DISCOURSE_URL=https://$host DISCOURSE_API_KEY=<nøglen> CONTACT_EMAIL=<supportadresse> python3 $HERE/configure.py
4. Slet API-nøglen igen under Admin → API, når opsætningen er færdig.
NEXT

81
docs/community.md Normal file
View file

@ -0,0 +1,81 @@
# Community-websted og forum
SQLInsight Pro har to offentlige websteder. Begge drives af SEOSoft:
| Websted | Adresse (eksempel) | Hvad |
|---|---|---|
| Community-webstedet | `https://sqlinsight.seosoft.dk` | Forside, funktioner, download af signerede udgivelser, dokumentation, SQL Server-versioner og supportdatoer, regler og supportaftaler. Projektet `src/SQLInsight.Community`. |
| Forummet | `https://forum.sqlinsight.seosoft.dk` | Discourse på dansk: spørgsmål og svar (med løste svar), installation og drift, performance, regler, idéer og annonceringer. |
Adresserne er eksempler. Ret dem i `deploy/forum/app.yml`, `deploy/community/docker-compose.yml` og i webbrugerfladens `SQLInsight:Web:CommunityUrl` og `SQLInsight:Web:ForumUrl`.
Kør de to på hver sin server (eller VM). Discourse vil selv eje port 80 og 443 og er svær at dele en server med.
## Forummet (Discourse)
Discourse installeres med den officielle `discourse_docker`-launcher. Det er den eneste måde, Discourse understøtter.
1. **Server:** Ubuntu LTS eller Debian, 2 GB RAM (4 GB anbefales), 2 CPU'er og 20 GB disk.
2. **DNS:** Opret en A-post (og evt. AAAA) for forummets navn, der peger på serveren.
3. **Mail:** Discourse kan ikke fungere uden SMTP. Tilmelding, notifikationer og nulstilling af adgangskode sker på mail. Brug en rigtig mailudbyder med SPF og DKIM for afsenderdomænet.
4. **Konfiguration:** Udfyld værdierne markeret `CHANGE` i `deploy/forum/app.yml`: værtsnavn, administratoradresse, SMTP og Let's Encrypt-adresse.
5. **Installation:** Kør `sudo deploy/forum/install.sh`. Scriptet:
- stopper, hvis der stadig står eksempelværdier;
- installerer Docker, hvis det mangler;
- kloner `discourse_docker` til `/var/discourse` og kopierer `app.yml` på plads;
- kører `./launcher rebuild app`. Det tager 10-15 minutter.
6. **Administrator:** Åbn forummet, og opret en bruger med en af adresserne i `DISCOURSE_DEVELOPER_EMAILS`. Den bliver administrator.
7. **Opsætning som kode:** Lav en midlertidig API-nøgle (scriptet skriver kommandoen), og kør:
```
DISCOURSE_URL=https://forum.sqlinsight.seosoft.dk DISCOURSE_API_KEY=... \
COMMUNITY_URL=https://sqlinsight.seosoft.dk CONTACT_EMAIL=support@... \
python3 deploy/forum/configure.py
```
`configure.py` gør følgende:
- sætter titel, beskrivelser, dansk sprog, tags og løste svar (pluginnet `solved` følger med Discourse);
- opretter kategorierne med beskrivelser. Kun medarbejdere kan skrive i *Annonceringer*;
- opretter og fastgør et velkomstemne;
- skjuler Discourses engelske standardemne.
Scriptet kan køres igen. Det ændrer kun det, der afviger. Slet API-nøglen bagefter under *Admin → API*.
**Drift:**
- Opdatering: `cd /var/discourse && git pull && ./launcher rebuild app`, eller via */admin/upgrade*.
- Backup: Discourse tager dagligt backup til `/var/discourse/shared/standalone/backups`. Slå upload til S3 eller kopiering væk fra serveren til under *Admin → Backups*.
- Logge: `./launcher logs app`.
**Forslag til moderation:**
- Lad SEOSoft-medarbejdere have rollen *moderator*.
- Markér det bedste svar som løsning, så svaret kan findes igen.
- Kunder med supportaftale skal bruge mail- eller telefonsupport til sager med kundedata. Forummet er offentligt.
## Community-webstedet
Webstedet er en lille ASP.NET Core-applikation uden database. Det viser:
- **Udgivelser:** Kun udgivelser fra det signerede opdateringsfeed med en gyldig SEOSoft-signatur vises. Webstedet viser altså præcis det, agenterne ville installere, med SHA-256 for hver fil.
- **SQL Server-versioner:** Seneste CU'er og supportstatus, fra den nyeste signerede regelpakke i feedet.
- **Dokumentation:** Filerne i `docs/`, bygget ind i programmet. Links mellem dokumenterne bliver til sider, og links til andre filer i repositoriet går til Forgejo. HTML i Markdown vises ikke.
- **Forum:** De seneste emner fra forummets offentlige `/latest.json`. Hvis forummet er nede, virker webstedet stadig.
Kør det med Docker på sin egen server. Caddy henter og fornyr selv TLS-certifikatet:
```
cd deploy/community
COMMUNITY_HOST=sqlinsight.seosoft.dk COMMUNITY_CONTACT=salg@... docker compose up -d
```
| Indstilling (`SQLInsight:Community:…`) | Standard | Betydning |
|---|---|---|
| `Feed` | (ingen) | Det signerede opdateringsfeed. Normalt hubbens `https://<hub>/updates/`, eller en mappe. Uden feed vises ingen udgivelser, og versionssiden bruger den indbyggede regelpakke. |
| `ForumUrl` | (ingen) | Forummets adresse. Bruges til links, `/forum` og de seneste emner. |
| `SourceUrl` | Forgejo-repositoriet | Link til kildekoden og til filer, som dokumentationen henviser til. |
| `ContactEmail` | `salg@example.com` | Adressen på supportsiden. Skal sættes. |
| `TrustedKeys:<id>` | (ingen) | Offentlige nøgler ud over de indbyggede. Brug samme værdi som for agenterne. |
| `CacheFor` | `00:10:00` | Hvor længe feed og forumemner caches. |
Webstedet sender strenge sikkerhedsheadere (CSP uden inline-scripts og -styles, `X-Frame-Options: DENY`). Det har ingen login og gemmer ingen data om besøgende.
Uden Docker: `dotnet publish src/SQLInsight.Community -c Release`. Kør det derefter som en Windows-tjeneste eller en systemd-tjeneste bag IIS, nginx eller Caddy.

View file

@ -0,0 +1,23 @@
namespace SQLInsight.Community;
public sealed class CommunityOptions
{
public const string SectionName = "SQLInsight:Community";
/// <summary>The signed update feed (https or folder), where releases and rule packs are published.</summary>
public string? Feed { get; set; }
/// <summary>The Discourse forum, e.g. https://forum.sqlinsight.seosoft.dk/.</summary>
public Uri? ForumUrl { get; set; }
public Uri SourceUrl { get; set; } = new("https://git.seosoft.dk/seo/sqlinsight-pro");
/// <summary>Where Professional and Enterprise enquiries go.</summary>
public string ContactEmail { get; set; } = "salg@example.com";
/// <summary>Public keys trusted for the feed, in addition to the embedded ones (key id → base64 SubjectPublicKeyInfo).</summary>
public IDictionary<string, string> TrustedKeys { get; } = new Dictionary<string, string>();
/// <summary>How long releases, the rule pack and forum topics are cached.</summary>
public TimeSpan CacheFor { get; set; } = TimeSpan.FromMinutes(10);
}

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="SQLInsight Pro: gratis SQL Server-overvågning med dyb ekspertise. Forklaringer, evidens og konkrete anbefalinger, on-prem." />
<base href="/" />
<link rel="icon" type="image/svg+xml" href="@Assets["favicon.svg"]" />
<link rel="stylesheet" href="@Assets["site.css"]" />
<HeadOutlet />
</head>
<body>
<Routes />
</body>
</html>

View file

@ -0,0 +1,40 @@
@inherits LayoutComponentBase
@inject IOptions<CommunityOptions> Options
<header class="site-header">
<div class="wrap header-row">
<a class="brand" href="/"><span class="logo" aria-hidden="true">SI</span><span>SQLInsight <strong>Pro</strong></span></a>
<nav class="nav" aria-label="Hovedmenu">
<NavLink href="/funktioner">Funktioner</NavLink>
<NavLink href="/download">Download</NavLink>
<NavLink href="/dokumentation">Dokumentation</NavLink>
<NavLink href="/sql-server-versioner">SQL Server-versioner</NavLink>
<NavLink href="/regler">Regler</NavLink>
<NavLink href="/support">Support</NavLink>
@if (Options.Value.ForumUrl is { } forum)
{
<a class="button small" href="@forum" rel="noopener">Forum</a>
}
</nav>
</div>
</header>
<main>
@Body
</main>
<footer class="site-footer">
<div class="wrap footer-row">
<div>
<strong>SQLInsight Pro</strong><br />
Gratis og open source (MIT). Udviklet af SEOSoft.
</div>
<div class="footer-links">
@if (Options.Value.ForumUrl is { } forumLink)
{
<a href="@forumLink" rel="noopener">Forum</a>
}
<a href="@Options.Value.SourceUrl" rel="noopener">Kildekode og issues</a>
<a href="/dokumentation/databehandling">Databehandling</a>
<a href="/support">Support og priser</a>
</div>
</div>
</footer>

View file

@ -0,0 +1,57 @@
@page "/dokumentation"
@page "/dokumentation/{Slug}"
@inject DocsService Docs
@inject NavigationManager Navigation
@if (_page is null && Slug is not null)
{
return;
}
<PageTitle>@(_page?.Title ?? "Dokumentation") · SQLInsight Pro</PageTitle>
<div class="wrap doc-layout">
<nav class="doc-nav" aria-label="Dokumentation">
<a href="/dokumentation" class="@(Slug is null ? "active" : "")">Oversigt</a>
@foreach (var doc in Docs.All)
{
<a href="/dokumentation/@doc.Slug" class="@(string.Equals(doc.Slug, Slug, StringComparison.OrdinalIgnoreCase) ? "active" : "")">@doc.Title</a>
}
</nav>
<article class="doc">
@if (_page is null)
{
<h1>Dokumentation</h1>
<p class="lead">Alt fra installation til egne regler. Samme tekster som i kildekoden, så de altid passer til den nyeste version.</p>
<div class="grid mt">
@foreach (var doc in Docs.All)
{
<a class="card card-link" href="/dokumentation/@doc.Slug">
<h3>@doc.Title</h3>
<p class="muted">@doc.Summary</p>
</a>
}
</div>
}
else
{
<h1>@_page.Title</h1>
@((MarkupString)_page.Html)
}
</article>
</div>
@code {
[Parameter] public string? Slug { get; set; }
private DocPage? _page;
protected override void OnParametersSet()
{
_page = Slug is null ? null : Docs.Find(Slug);
if (_page is null && Slug is not null)
{
Navigation.NotFound();
}
}
}

View file

@ -0,0 +1,95 @@
@page "/download"
@using SQLInsight.Updates
@inject ReleaseService Releases
@inject IOptions<CommunityOptions> Options
<PageTitle>Download · SQLInsight Pro</PageTitle>
<div class="wrap page narrow">
<h1>Download</h1>
<p class="lead">Gratis, også til produktion. Alle filer er signeret af SEOSoft, og SHA-256 står ved hver fil.</p>
@if (_state.Channels.Count == 0 || _state.Channels.All(c => c.Manifest.Release is null))
{
<div class="alert">Der er ingen udgivelser endnu. Byg fra kildekoden på <a href="@Options.Value.SourceUrl" rel="noopener">Forgejo</a>, eller se <a href="/dokumentation/installation">installationsvejledningen</a>.</div>
}
@foreach (var channel in _state.Channels.Where(c => c.Manifest.Release is not null))
{
var release = channel.Manifest.Release!;
<section class="card mt">
<h2 class="flush">SQLInsight Pro @release.Version
<span class="badge @(channel.Channel == UpdateManifest.StableChannel ? "ok" : "warn")">@(channel.Channel == UpdateManifest.StableChannel ? "Stabil" : "Preview")</span>
@if (release.Security) { <span class="badge bad">Sikkerhedsopdatering</span> }
</h2>
<p class="muted">Udgivet @release.Released.ToString("d. MMMM yyyy", Danish)@(release.NotesUrl is { } notes ? "" : ".")
@if (release.NotesUrl is { } url) { <span> · <a href="@url" rel="noopener">Hvad er nyt</a></span> }
</p>
<div class="table-scroll">
<table>
<thead><tr><th>Fil</th><th>Til</th><th>Størrelse</th></tr></thead>
<tbody>
@foreach (var asset in release.Assets.OrderBy(a => Order(a.Platform)))
{
<tr>
<td>
@if (Releases.DownloadUrl(asset.File) is { } link)
{
<a href="@link"><strong>@System.IO.Path.GetFileName(asset.File.Path)</strong></a>
}
else
{
<strong>@System.IO.Path.GetFileName(asset.File.Path)</strong>
}
<div class="hash" title="SHA-256">SHA-256 @asset.File.Sha256.ToLowerInvariant()</div>
</td>
<td>@Describe(asset.Platform)</td>
<td class="nowrap">@Size(asset.File.Size)</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
<h2>Kom i gang</h2>
<ol>
<li>Opret en tom database til SQLInsight på en SQL Server 2016 eller nyere.</li>
<li>Giv agentens konto læserettigheder på de instanser, der skal overvåges, med scriptet <code>grant-least-privilege.sql</code>.</li>
<li>Kør MSI'en, og angiv forbindelsesstrengen og den første server. Eller installer stille:</li>
</ol>
<pre><code>msiexec /i @MsiName /qn /l*v install.log `
REPOSITORY_CONNECTION="Server=sqlmon01;Database=SQLInsight;Integrated Security=true;Encrypt=true" `
MONITOR_SERVER=sql01</code></pre>
<p>Åbn derefter <code>http://serveren:5080</code>. Hele vejledningen: <a href="/dokumentation/installation">Installation</a>.</p>
<p class="muted small-text">Kildekoden er under MIT-licens på <a href="@Options.Value.SourceUrl" rel="noopener">Forgejo</a>.</p>
</div>
@code {
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
private FeedState _state = new([], SQLInsight.Rules.Lifecycle.RulePack.Embedded(), null);
private AppRelease? Stable => _state.Channels.FirstOrDefault(c => c.Channel == UpdateManifest.StableChannel)?.Manifest.Release;
private string MsiName => Stable?.Assets.FirstOrDefault(a => a.Platform == "win-x64-msi") is { } msi
? System.IO.Path.GetFileName(msi.File.Path)
: "SQLInsight-Pro-1.0.0-x64.msi";
protected override async Task OnInitializedAsync() => _state = await Releases.GetAsync(CancellationToken.None);
private static int Order(string platform) => platform switch { "win-x64-msi" => 0, "win-x64" => 1, _ => 2 };
private static string Describe(string platform) => platform switch
{
"win-x64-msi" => "Windows, installationsprogram (anbefalet)",
"win-x64" => "Windows, zip til manuel installation",
"linux-x64" => "Linux x64 (agent)",
"linux-arm64" => "Linux ARM64 (agent)",
_ => platform,
};
private static string Size(long bytes) => bytes >= 1024 * 1024
? (bytes / 1024d / 1024d).ToString("0.#", Danish) + " MB"
: (bytes / 1024d).ToString("0", Danish) + " kB";
}

View file

@ -0,0 +1,7 @@
@page "/fejl"
<PageTitle>Fejl · SQLInsight Pro</PageTitle>
<div class="wrap page narrow">
<h1>Noget gik galt</h1>
<p>Prøv igen om lidt. Fortsætter det, så skriv i forummet.</p>
</div>

View file

@ -0,0 +1,48 @@
@page "/funktioner"
<PageTitle>Funktioner · SQLInsight Pro</PageTitle>
<div class="wrap page">
<h1>Hvad SQLInsight Pro finder</h1>
<p class="lead">Blocking og deadlocks er kun ét område. Regelmotoren dækker hele instansen, og hvert fund kommer med de målinger, der udløste det, en forklaring og en anbefaling.</p>
<div class="grid mt">
<div class="card">
<h3>Performance</h3>
<p class="muted">Wait statistics med fortolkning, blocking-kæder og deadlock-grafer, Query Store-regressioner og forcerede planer der fejler, parameterfølsomme forespørgsler, spills, implicitte konverteringer, CPU- og hukommelsespres, I/O-latens pr. fil.</p>
</div>
<div class="card">
<h3>Indexes og statistik</h3>
<p class="muted">Manglende indexes vurderet mod de eksisterende, ubrugte og duplikerede indexes, heaps med forwarded records og forældede statistikker.</p>
</div>
<div class="card">
<h3>Drift og datasikkerhed</h3>
<p class="muted">Backup-dækning og log-backups, DBCC CHECKDB, korrupte sider, Always On-sundhed og datatab, SQL Agent-jobs, der fejler.</p>
</div>
<div class="card">
<h3>Konfiguration</h3>
<p class="muted">MAXDOP efter NUMA-layout, cost threshold, max server memory, tempdb, VLF'er, autogrowth, compatibility level og trace flags.</p>
</div>
<div class="card">
<h3>Versioner og opdateringer</h3>
<p class="muted">Advarer, når en instans mangler CU'er eller sikkerhedsopdateringer, og når versionen er på vej ud af Microsofts support.</p>
</div>
<div class="card">
<h3>Afvigelser fra det normale</h3>
<p class="muted">Baselines pr. time på ugen, så usædvanlig CPU, trafik der falder bort eller page life expectancy, der styrtdykker, bliver opdaget.</p>
</div>
</div>
<h2>Og alt det omkring</h2>
<div class="grid">
<div class="card"><h3>Notifikationer</h3><p class="muted">E-mail, Microsoft Teams, webhook og Windows' hændelseslog (SCOM).</p></div>
<div class="card"><h3>Integration</h3><p class="muted">Prometheus og Grafana-dashboard, JSON-API.</p></div>
<div class="card"><h3>Rapporter</h3><p class="muted">Driftsrapport som PDF til ledelse og revision, også som månedlig mail.</p></div>
<div class="card"><h3>Egne regler</h3><p class="muted">Skriv regler i SQL, eller brug community-reglerne. <a href="/regler">Se alle regler</a>.</p></div>
</div>
<div class="actions">
<a class="button" href="/download">Download</a>
<a class="button secondary" href="/dokumentation/installation">Læs installationsvejledningen</a>
</div>
</div>

View file

@ -0,0 +1,124 @@
@page "/"
@inject ReleaseService Releases
@inject ForumService Forum
@inject IOptions<CommunityOptions> Options
<PageTitle>SQLInsight Pro · Gratis SQL Server-overvågning med dyb ekspertise</PageTitle>
<section class="hero">
<div class="wrap hero-grid">
<div>
<div class="eyebrow">Gratis · open source · on-prem</div>
<h1>SQL Server-overvågning, der forklarer hvad du skal gøre</h1>
<p class="lead">
SQLInsight Pro finder problemerne i jeres SQL Servere og siger, <strong>hvorfor</strong> de er et problem,
<strong>hvad</strong> målingerne viser, og <strong>hvordan</strong> I løser dem. Fra SQL Server 2000 til 2025 og Azure SQL Managed Instance.
</p>
<div class="actions">
<a class="button" href="/download">Download gratis</a>
<a class="button secondary" href="/funktioner">Se hvad den finder</a>
</div>
<div class="stats">
<div><strong>@_rules</strong><span>regler med forklaring</span></div>
<div><strong>2000–2025</strong><span>alle versioner</span></div>
<div><strong>0 kr.</strong><span>for hele produktet</span></div>
</div>
</div>
<div class="finding-demo" aria-label="Eksempel på et fund">
<span class="badge bad">Kritisk</span>
<h3>Databasen Økonomi har ikke haft en fuld backup i 9 dage</h3>
<p class="muted small-text">Uden en nylig fuld backup kan databasen ikke gendannes til et kendt punkt, og log-backups kan ikke bruges alene.</p>
<dl>
<dt>Seneste fulde backup</dt><dd>2026-09-20 22:04</dd>
<dt>Recovery model</dt><dd>FULL</dd>
<dt>Størrelse</dt><dd>184 GB</dd>
</dl>
<p class="small-text"><strong>Anbefaling:</strong> Tag en fuld backup nu, og kontrollér det job, der skulle have taget den. SQLInsight foreslår scriptet; det køres aldrig automatisk.</p>
</div>
</div>
</section>
<section class="section alt">
<div class="wrap">
<div class="grid">
<div class="card">
<h3>Dyb ekspertise, ikke bare grafer</h3>
<p class="muted">Wait stats, Query Store, planer, indexes, backup, CHECKDB, Always On, konfiguration og versioner. Hvert fund har evidens, forklaring og en konkret anbefaling.</p>
</div>
<div class="card">
<h3>Kører hos jer</h3>
<p class="muted">Agent, database og webbrugerflade installeres on-prem med én MSI. Der sendes intet ud af huset, med mindre I selv slår det til.</p>
</div>
<div class="card">
<h3>Altid opdateret</h3>
<p class="muted">Nye CU'er og supportdatoer kommer som signerede regelpakker, uden at programmet skal opdateres. Selve programmet kan opdatere sig selv.</p>
</div>
<div class="card">
<h3>Hjælp, når I har brug for det</h3>
<p class="muted">Spørg i forummet, eller få prioriteret mail- og telefonsupport fra erfarne SQL Server-folk med Professional og Enterprise.</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap grid">
<div>
<h2>Seneste fra forummet</h2>
@if (_topics.Count == 0)
{
<p class="muted">Stil spørgsmål, del erfaringer og foreslå nye regler i forummet.</p>
}
else
{
<ul class="topics">
@foreach (var topic in _topics)
{
<li>
<a href="@topic.Url" rel="noopener">@topic.Title</a>
<span class="muted small-text nowrap">@(topic.Solved ? "Løst · " : "")@topic.Replies svar</span>
</li>
}
</ul>
}
@if (Options.Value.ForumUrl is { } forum)
{
<p><a href="@forum" rel="noopener">Gå til forummet →</a></p>
}
</div>
<div>
<h2>Nyeste SQL Server-opdateringer</h2>
<ul class="topics">
@foreach (var build in _builds)
{
<li>
<span>@build.Release.DisplayName() <strong>@build.Name</strong></span>
<span class="muted small-text nowrap">@build.Released.ToString("d. MMM yyyy", Danish)</span>
</li>
}
</ul>
<p><a href="/sql-server-versioner">Alle versioner og supportdatoer →</a></p>
</div>
</div>
</section>
@code {
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
private IReadOnlyList<ForumTopic> _topics = [];
private IReadOnlyList<SQLInsight.Rules.Lifecycle.KnownBuild> _builds = [];
private int _rules;
protected override async Task OnInitializedAsync()
{
var feed = await Releases.GetAsync(CancellationToken.None);
_rules = RuleCatalog.All(feed.RulePack.CommunityRules).Count;
_builds = Enum.GetValues<SQLInsight.Core.Instances.SqlServerRelease>()
.Select(feed.RulePack.Builds.Latest)
.OfType<SQLInsight.Rules.Lifecycle.KnownBuild>()
.OrderByDescending(b => b.Released).ThenByDescending(b => b.Release)
.Take(5)
.ToList();
_topics = await Forum.LatestAsync(6, CancellationToken.None);
}
}

View file

@ -0,0 +1,7 @@
@page "/ikke-fundet"
<PageTitle>Ikke fundet · SQLInsight Pro</PageTitle>
<div class="wrap page narrow">
<h1>Siden findes ikke</h1>
<p>Prøv <a href="/dokumentation">dokumentationen</a>, eller gå til <a href="/">forsiden</a>.</p>
</div>

View file

@ -0,0 +1,35 @@
@page "/regler"
@inject ReleaseService Releases
<PageTitle>Regler · SQLInsight Pro</PageTitle>
<div class="wrap page">
<h1>@_rules.Count regler</h1>
<p class="lead">Hver regel måler noget konkret og forklarer fundet. Kan en regel ikke måles på en version eller med de rettigheder, agenten har, står den som "ikke målbar", aldrig som "OK".</p>
<p><a href="/dokumentation/regler">Skriv dine egne regler i SQL, eller bidrag med en community-regel →</a></p>
@foreach (var area in _rules.GroupBy(r => r.Area).OrderBy(g => g.Key, StringComparer.Create(CultureInfo.GetCultureInfo("da-DK"), false)))
{
<h2>@area.Key <span class="muted small-text">(@area.Count())</span></h2>
<div class="table-scroll">
<table>
<tbody>
@foreach (var rule in area.OrderBy(r => r.Id, StringComparer.Ordinal))
{
<tr>
<td class="mono nowrap">@rule.Id</td>
<td>@rule.Title</td>
<td class="nowrap">@if (rule.Community) { <span class="badge accent">Community@(rule.Author is { } a ? $" · {a}" : "")</span> }</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private IReadOnlyList<RuleEntry> _rules = [];
protected override async Task OnInitializedAsync() => _rules = RuleCatalog.All((await Releases.GetAsync(CancellationToken.None)).RulePack.CommunityRules);
}

View file

@ -0,0 +1,65 @@
@page "/sql-server-versioner"
@using SQLInsight.Core.Instances
@using SQLInsight.Rules.Lifecycle
@inject ReleaseService Releases
@inject TimeProvider Time
<PageTitle>SQL Server-versioner, seneste CU og supportdatoer · SQLInsight Pro</PageTitle>
<div class="wrap page">
<h1>SQL Server-versioner</h1>
<p class="lead">Seneste cumulative update eller sikkerhedsopdatering for hver version, og hvornår Microsoft stopper supporten. Samme data, som SQLInsight Pro bruger til at advare om manglende opdateringer.</p>
<p class="muted small-text">Regelpakke @_pack.Version · katalog pr. @_pack.Builds.AsOf.ToString("d. MMMM yyyy", Danish). Kilder: Microsoft Lifecycle og Microsoft Learn.</p>
<div class="table-scroll mt">
<table>
<thead><tr><th>Version</th><th>Seneste opdatering</th><th>Build</th><th>Udgivet</th><th>Almindelig support til</th><th>Udvidet support til</th><th>Status</th></tr></thead>
<tbody>
@foreach (var row in _rows)
{
<tr>
<td class="nowrap"><strong>@row.Release.DisplayName()</strong></td>
<td>@(row.Build is { } b ? $"{b.Name}" : "–")@if (row.Build is { Kb.Length: > 0 } kb) { <br /><span class="muted small-text">@kb.Kb</span> }</td>
<td class="mono nowrap">@(row.Build?.Build.ToString() ?? "–")</td>
<td class="nowrap">@(row.Build?.Released.ToString("d. MMM yyyy", Danish) ?? "–")</td>
<td class="nowrap">@Date(row.Lifecycle?.MainstreamEnd)</td>
<td class="nowrap">@Date(row.Lifecycle?.ExtendedEnd)</td>
<td><span class="badge @row.Status.Css">@row.Status.Text</span></td>
</tr>
}
</tbody>
</table>
</div>
<p class="muted small-text">Udvidet support betyder kun sikkerhedsopdateringer. Extended Security Updates (betalt) er ikke medregnet. Azure SQL Managed Instance opdateres af Microsoft og er ikke med.</p>
</div>
@code {
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
private sealed record Row(SqlServerRelease Release, KnownBuild? Build, SupportLifecycle? Lifecycle, (string Text, string Css) Status);
private RulePack _pack = RulePack.Embedded();
private List<Row> _rows = [];
protected override async Task OnInitializedAsync()
{
_pack = (await Releases.GetAsync(CancellationToken.None)).RulePack;
var today = DateOnly.FromDateTime(Time.GetUtcNow().UtcDateTime);
_rows = Enum.GetValues<SqlServerRelease>()
.Where(r => r is not (SqlServerRelease.Unknown or SqlServerRelease.AzureSqlManagedInstance))
.OrderByDescending(r => r)
.Select(r => new Row(r, _pack.Builds.Latest(r), _pack.Lifecycle.Find(r), Status(_pack.Lifecycle.Find(r), today)))
.ToList();
}
public static (string Text, string Css) Status(SupportLifecycle? lifecycle, DateOnly today) => lifecycle switch
{
{ ExtendedEnd: { } end } when end < today => ("Ude af support", "bad"),
{ ExtendedEnd: { } end } when end < today.AddYears(1) => ("Support slutter inden for et år", "warn"),
{ MainstreamEnd: { } end } when end < today => ("Kun sikkerhedsopdateringer", "warn"),
{ MainstreamEnd: not null } => ("Fuld support", "ok"),
_ => ("Datoer ikke offentliggjort", "accent"),
};
private static string Date(DateOnly? date) => date?.ToString("d. MMM yyyy", Danish) ?? "–";
}

View file

@ -0,0 +1,64 @@
@page "/support"
@inject IOptions<CommunityOptions> Options
<PageTitle>Support og priser · SQLInsight Pro</PageTitle>
<div class="wrap page">
<h1>Support og priser</h1>
<p class="lead">Hele programmet er gratis. Betalte aftaler giver adgang til mennesker med SQL Server-erfaring og en svartid, ikke til flere funktioner.</p>
<div class="plans mt">
<div class="card plan">
<h3>Community</h3>
<div class="price">Gratis</div>
<ul>
<li>Al overvågning, analyse og alle regler</li>
<li>Opdateringer af program og regelpakker</li>
<li>Rapporter, API og integrationer</li>
<li>Hjælp i forummet fra fællesskabet</li>
</ul>
<a class="button secondary" href="/download">Download</a>
</div>
<div class="card plan featured">
<h3>Professional</h3>
<div class="price">Abonnement</div>
<ul>
<li>Alt i Community</li>
<li>Prioriteret mailsupport med svartidsgaranti</li>
<li>Hjælp til at fortolke fund</li>
<li>Valgfrit: vi følger jeres kritiske fund via SQLInsight Hub</li>
</ul>
<a class="button" href="mailto:@Options.Value.ContactEmail?subject=SQLInsight%20Pro%20Professional">Kontakt os</a>
</div>
<div class="card plan">
<h3>Enterprise</h3>
<div class="price">Abonnement</div>
<ul>
<li>Alt i Professional</li>
<li>Telefonsupport</li>
<li>Proaktiv opfølgning via SQLInsight Hub</li>
<li>Hjælp til tuning og opgraderinger</li>
</ul>
<a class="button secondary" href="mailto:@Options.Value.ContactEmail?subject=SQLInsight%20Pro%20Enterprise">Kontakt os</a>
</div>
</div>
<h2>Hvor får jeg hjælp?</h2>
<div class="grid">
<div class="card">
<h3>Forummet</h3>
<p class="muted">Spørgsmål om installation, fund og tuning. Andre brugere og SEOSoft svarer; gode svar markeres som løsningen.</p>
@if (Options.Value.ForumUrl is { } forum) { <a href="@forum" rel="noopener">Gå til forummet →</a> }
</div>
<div class="card">
<h3>Fejl og forslag</h3>
<p class="muted">Har du fundet en fejl, eller har du en idé til en regel? Opret et issue, eller bidrag direkte med en community-regel.</p>
<a href="@(new Uri(Options.Value.SourceUrl.AbsoluteUri.TrimEnd('/') + "/issues"))" rel="noopener">Issues på Forgejo →</a>
</div>
<div class="card">
<h3>Dokumentation</h3>
<p class="muted">Installation, opdateringer, integration, rapporter og egne regler.</p>
<a href="/dokumentation">Til dokumentationen →</a>
</div>
</div>
</div>

View file

@ -0,0 +1,5 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(SiteLayout)" />
</Found>
</Router>

View file

@ -0,0 +1,11 @@
@using System.Globalization
@using System.Net.Http
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Options
@using SQLInsight.Community
@using SQLInsight.Community.Components
@using SQLInsight.Community.Components.Layout
@using SQLInsight.Community.Services
@using SQLInsight.Core.Instances

View file

@ -0,0 +1,43 @@
using SQLInsight.Community;
using SQLInsight.Community.Components;
using SQLInsight.Community.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Community");
builder.Services.AddSystemd();
builder.Services.Configure<CommunityOptions>(builder.Configuration.GetSection(CommunityOptions.SectionName));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHttpClient("feed", c => c.Timeout = TimeSpan.FromSeconds(20));
builder.Services.AddHttpClient("forum", c => c.Timeout = TimeSpan.FromSeconds(5));
builder.Services.AddSingleton<ReleaseService>();
builder.Services.AddSingleton<ForumService>();
builder.Services.AddSingleton<DocsService>();
builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All));
builder.Services.AddRazorComponents();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/fejl", createScopeForErrors: true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/ikke-fundet");
app.Use(async (context, next) =>
{
context.Response.Headers.XContentTypeOptions = "nosniff";
context.Response.Headers.XFrameOptions = "DENY";
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; form-action 'self'";
await next();
});
app.UseAntiforgery();
app.MapStaticAssets();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapGet("/forum", (Microsoft.Extensions.Options.IOptions<CommunityOptions> options) =>
options.Value.ForumUrl is { } forum ? Results.Redirect(forum.ToString()) : Results.NotFound());
app.MapRazorComponents<App>();
app.Run();
/// <summary>Entry point, visible to integration tests.</summary>
public partial class Program;

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>SQLInsight.Community</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Rules\SQLInsight.Rules.csproj" />
<ProjectReference Include="..\SQLInsight.Updates\SQLInsight.Updates.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
</ItemGroup>
<ItemGroup>
<!-- The documentation in docs/ is the documentation on the site: one source. -->
<EmbeddedResource Include="..\..\docs\*.md" LogicalName="docs.%(Filename)%(Extension)" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,100 @@
using Markdig;
using Markdig.Extensions.AutoIdentifiers;
using Markdig.Renderers;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using Microsoft.Extensions.Options;
namespace SQLInsight.Community.Services;
public sealed record DocPage(string Slug, string Title, string Summary, string Html);
/// <summary>
/// The documentation in the repository's docs/ folder, rendered as pages. Links between the documents become links on
/// the site; links to other files in the repository go to the source on Forgejo. Raw HTML in the Markdown is not rendered.
/// </summary>
public sealed class DocsService
{
/// <summary>Order and one-line summaries for the documentation index. Documents not listed are not published.</summary>
private static readonly (string Slug, string Summary)[] Index =
[
("installation", "Repository-database, rettigheder, MSI-installeren, agent og web som tjenester eller i IIS."),
("opdatering", "Regelpakker, nye versioner, stable og preview, installation uden internetadgang."),
("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."),
("rapporter", "Driftsrapport til ledelse og revision som PDF, også som månedlig mail."),
("regler", "Skriv egne regler i SQL, og bidrag med community-regler."),
("databehandling", "Hvilke data der behandles, hvor de ligger, og hvad der deles."),
("legacy-versions", "SQL Server 2000, 2005 og 2008: hvad der kan måles, og hvordan der forbindes."),
];
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.UseAutoIdentifiers(AutoIdentifierOptions.GitHub)
.DisableHtml()
.Build();
private readonly Dictionary<string, DocPage> _pages;
public DocsService(IOptions<CommunityOptions> options)
{
ArgumentNullException.ThrowIfNull(options);
var source = options.Value.SourceUrl.AbsoluteUri.TrimEnd('/');
var assembly = typeof(DocsService).Assembly;
_pages = Index.Select(entry =>
{
using var stream = assembly.GetManifestResourceStream($"docs.{entry.Slug}.md")
?? throw new InvalidOperationException($"docs/{entry.Slug}.md mangler.");
using var reader = new StreamReader(stream);
return Render(entry.Slug, entry.Summary, reader.ReadToEnd(), source);
}).ToDictionary(p => p.Slug, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<DocPage> All => Index.Select(e => _pages[e.Slug]).ToList();
public DocPage? Find(string slug) => _pages.GetValueOrDefault(slug);
public static DocPage Render(string slug, string summary, string markdown, string sourceUrl)
{
var document = Markdown.Parse(markdown, Pipeline);
var title = document.Descendants<HeadingBlock>().FirstOrDefault(h => h.Level == 1) is { Inline: { } inline }
? string.Concat(inline.Descendants<LiteralInline>().Select(l => l.Content.ToString()))
: slug;
foreach (var link in document.Descendants<LinkInline>())
{
link.Url = Rewrite(link.Url, sourceUrl);
}
// The page shows the title itself.
if (document.FirstOrDefault(b => b is HeadingBlock { Level: 1 }) is { } h1)
{
document.Remove(h1);
}
using var writer = new StringWriter();
var renderer = new HtmlRenderer(writer);
Pipeline.Setup(renderer);
renderer.Render(document);
return new DocPage(slug, title, summary, writer.ToString());
}
/// <summary>installation.md#x → /dokumentation/installation#x; ../db/x.sql → the file on Forgejo; absolute and anchor links unchanged.</summary>
public static string? Rewrite(string? url, string sourceUrl)
{
if (string.IsNullOrEmpty(url) || url.StartsWith('#') || Uri.TryCreate(url, UriKind.Absolute, out _))
{
return url;
}
var hash = url.IndexOf('#', StringComparison.Ordinal);
var path = hash < 0 ? url : url[..hash];
var anchor = hash < 0 ? string.Empty : url[hash..];
if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) && !path.Contains('/', StringComparison.Ordinal))
{
return "/dokumentation/" + path[..^3] + anchor;
}
var repositoryPath = path.StartsWith("../", StringComparison.Ordinal) ? path[3..] : "docs/" + path;
return $"{sourceUrl}/src/branch/main/{repositoryPath}{anchor}";
}
}

View file

@ -0,0 +1,77 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace SQLInsight.Community.Services;
public sealed record ForumTopic(string Title, Uri Url, int Replies, DateTimeOffset LastActivity, bool Solved);
/// <summary>
/// The latest topics from the Discourse forum (its public /latest.json), for the front page. If the forum cannot be
/// reached the site still works; it just shows the link.
/// </summary>
public sealed partial class ForumService(IOptions<CommunityOptions> options, IHttpClientFactory http, TimeProvider time, ILogger<ForumService> logger) : IDisposable
{
private readonly SemaphoreSlim _gate = new(1, 1);
public void Dispose() => _gate.Dispose();
private (IReadOnlyList<ForumTopic> Topics, DateTimeOffset At)? _cache;
public async Task<IReadOnlyList<ForumTopic>> LatestAsync(int count, CancellationToken cancellationToken)
{
if (options.Value.ForumUrl is not { } forum)
{
return [];
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cache is not { } cached || time.GetUtcNow() - cached.At >= options.Value.CacheFor)
{
_cache = (await FetchAsync(forum, cancellationToken).ConfigureAwait(false), time.GetUtcNow());
}
return _cache.Value.Topics.Take(count).ToList();
}
finally
{
_gate.Release();
}
}
private async Task<IReadOnlyList<ForumTopic>> FetchAsync(Uri forum, CancellationToken cancellationToken)
{
try
{
var client = http.CreateClient("forum");
await using var stream = await client.GetStreamAsync(new Uri(forum, "latest.json"), cancellationToken).ConfigureAwait(false);
var latest = await JsonSerializer.DeserializeAsync<Latest>(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return (latest?.TopicList?.Topics ?? [])
.Where(t => !t.Pinned && !string.IsNullOrWhiteSpace(t.Title))
.Select(t => new ForumTopic(t.Title!, new Uri(forum, $"t/{Uri.EscapeDataString(t.Slug ?? "emne")}/{t.Id}"), Math.Max(0, t.PostsCount - 1), t.BumpedAt, t.HasAcceptedAnswer))
.ToList();
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
LogForumUnavailable(logger, ex.Message);
return [];
}
}
[LoggerMessage(Level = LogLevel.Information, Message = "Forummet kunne ikke nås: {Message}")]
private static partial void LogForumUnavailable(ILogger logger, string message);
private sealed record Latest([property: JsonPropertyName("topic_list")] TopicList? TopicList);
private sealed record TopicList([property: JsonPropertyName("topics")] IReadOnlyList<Topic>? Topics);
private sealed record Topic(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string? Title,
[property: JsonPropertyName("slug")] string? Slug,
[property: JsonPropertyName("posts_count")] int PostsCount,
[property: JsonPropertyName("bumped_at")] DateTimeOffset BumpedAt,
[property: JsonPropertyName("pinned")] bool Pinned,
[property: JsonPropertyName("has_accepted_answer")] bool HasAcceptedAnswer);
}

View file

@ -0,0 +1,100 @@
using Microsoft.Extensions.Options;
using SQLInsight.Licensing;
using SQLInsight.Rules.Lifecycle;
using SQLInsight.Updates;
namespace SQLInsight.Community.Services;
public sealed record PublishedChannel(string Channel, UpdateManifest Manifest);
/// <summary>What the signed feed offers right now, per channel, plus the newest rule pack.</summary>
public sealed record FeedState(IReadOnlyList<PublishedChannel> Channels, RulePack RulePack, string? Error);
/// <summary>
/// Reads the signed update feed, so the site shows exactly what the agents would install: only releases with a
/// valid SEOSoft signature are listed, and the SQL Server version page uses the newest verified rule pack.
/// </summary>
public sealed partial class ReleaseService(IOptions<CommunityOptions> options, IHttpClientFactory http, TimeProvider time, ILogger<ReleaseService> logger) : IDisposable
{
private readonly SemaphoreSlim _gate = new(1, 1);
public void Dispose() => _gate.Dispose();
private (FeedState State, DateTimeOffset At)? _cache;
public async Task<FeedState> GetAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cache is { } cached && time.GetUtcNow() - cached.At < options.Value.CacheFor)
{
return cached.State;
}
var state = await LoadAsync(cancellationToken).ConfigureAwait(false);
_cache = (state, time.GetUtcNow());
return state;
}
finally
{
_gate.Release();
}
}
/// <summary>Where a feed file can be downloaded (feed paths are relative to the feed root).</summary>
public Uri? DownloadUrl(FileRef file)
{
ArgumentNullException.ThrowIfNull(file);
return Uri.TryCreate(options.Value.Feed, UriKind.Absolute, out var root) && root.Scheme is "https" or "http"
? new Uri(new Uri(root.AbsoluteUri.EndsWith('/') ? root.AbsoluteUri : root.AbsoluteUri + "/"), file.Path)
: null;
}
private async Task<FeedState> LoadAsync(CancellationToken cancellationToken)
{
var settings = options.Value;
var embedded = RulePack.Embedded();
if (string.IsNullOrWhiteSpace(settings.Feed))
{
return new FeedState([], embedded, null);
}
var keys = TrustedKeys.Embedded().With(settings.TrustedKeys);
var feed = new UpdateFeed(settings.Feed, http.CreateClient("feed"));
var channels = new List<PublishedChannel>();
var errors = new List<string>();
foreach (var channel in new[] { UpdateManifest.StableChannel, UpdateManifest.PreviewChannel })
{
try
{
var token = await feed.ReadManifestAsync(channel, cancellationToken).ConfigureAwait(false);
channels.Add(new PublishedChannel(channel, ManifestVerifier.Verify(token, keys, channel, time.GetUtcNow())));
}
catch (UpdateException ex)
{
errors.Add($"{channel}: {ex.Message}");
LogFeedError(logger, channel, ex.Message);
}
}
var pack = embedded;
if (channels.FirstOrDefault(c => c.Channel == UpdateManifest.StableChannel)?.Manifest.RulePack is { } reference
&& Version.TryParse(reference.Version, out var offered) && offered > embedded.Version)
{
try
{
pack = RulePack.Parse(await feed.DownloadAsync(reference.File, cancellationToken).ConfigureAwait(false), "regelpakke " + reference.Version);
}
catch (Exception ex) when (ex is UpdateException or InvalidDataException)
{
errors.Add(ex.Message);
LogFeedError(logger, "rulepack", ex.Message);
}
}
return new FeedState(channels, pack, errors.Count == 0 ? null : string.Join(" ", errors));
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Opdateringsfeedet ({What}): {Message}")]
private static partial void LogFeedError(ILogger logger, string what, string message);
}

View file

@ -0,0 +1,46 @@
using SQLInsight.Core.CustomRules;
using SQLInsight.Rules;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Community.Services;
public sealed record RuleEntry(string Id, string Title, string Area, string? Author, bool Community);
/// <summary>Every rule the product ships: the built-in ones and the community rules in the rule pack.</summary>
public static class RuleCatalog
{
private static readonly Dictionary<string, string> Areas = new(StringComparer.Ordinal)
{
["VER"] = "Version og opdateringer",
["CFG"] = "Konfiguration",
["DB"] = "Databaser, backup og integritet",
["WAIT"] = "Ventetider",
["IO"] = "I/O",
["CPU"] = "CPU og hukommelse",
["MEM"] = "CPU og hukommelse",
["ANOM"] = "Afvigelser fra det normale",
["QS"] = "Query Store og planer",
["PLAN"] = "Query Store og planer",
["IDX"] = "Indexes og statistik",
["STAT"] = "Indexes og statistik",
["AG"] = "Always On og SQL Agent",
["JOB"] = "Always On og SQL Agent",
["BLK"] = "Blocking og deadlocks",
["DLK"] = "Blocking og deadlocks",
["SEC"] = "Sikkerhed",
};
public static IReadOnlyList<RuleEntry> All(IReadOnlyList<CustomRuleDefinition> community)
{
ArgumentNullException.ThrowIfNull(community);
IEnumerable<RuleEntry> builtIn = BuiltInRules.Create().Select(r => new RuleEntry(r.Id, r.Title, Area(r.Id), null, false));
return builtIn.Concat(community.Select(r => new RuleEntry(r.Id, r.Title, Area(r.Id), r.Author, true))).ToList();
}
public static string Area(string id)
{
ArgumentNullException.ThrowIfNull(id);
var parts = id.Split('-');
return parts.Length >= 2 && Areas.TryGetValue(parts[1], out var area) ? area : "Andet";
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0e7490"/><stop offset="1" stop-color="#155e75"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#g)"/><text x="16" y="21" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="700" fill="#fff">SI</text></svg>

After

Width:  |  Height:  |  Size: 415 B

View file

@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,150 @@
/* SQLInsight Pro design system: light first, neutral surfaces, one accent, semantic severity colours. */
@font-face {
font-family: "Inter";
src: url("fonts/inter-latin-wght-normal.woff2") format("woff2");
font-weight: 100 900; font-style: normal; font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
src: url("fonts/inter-latin-ext-wght-normal.woff2") format("woff2");
font-weight: 100 900; font-style: normal; font-display: swap;
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f1f3f6;
--border: #e4e7ec;
--text: #101828;
--text-2: #475467;
--text-3: #98a2b3;
--accent: #0e7490; /* deep teal */
--accent-soft: #e0f2f6;
--accent-text: #0b5c72;
--critical: #d92d20; --critical-soft: #fee4e2;
--high: #e04f16; --high-soft: #ffead5;
--medium: #b54708; --medium-soft: #fef0c7;
--low: #1570ef; --low-soft: #d1e9ff;
--info: #667085; --info-soft: #f2f4f7;
--ok: #079455; --ok-soft: #dcfae6;
--radius: 12px;
--shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06);
--sidebar: 256px;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
font-feature-settings: "cv11", "ss01";
color-scheme: light;
}
/* Public site: light, calm, readable. Same tokens as the product itself. */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0c111d; --surface: #161b26; --surface-2: #1f2533; --border: #2a3140;
--text: #f5f5f6; --text-2: #cecfd2; --text-3: #85888e;
--accent: #22b8cf; --accent-soft: #0e2d36; --accent-text: #7dd9e8;
--critical-soft: #3b1512; --high-soft: #3a1d0d; --medium-soft: #3a2a07; --low-soft: #0f2744; --info-soft: #1f2533; --ok-soft: #0b2e1d;
--shadow: none; color-scheme: dark;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-size: 16px; line-height: 1.65; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3 { line-height: 1.25; letter-spacing: -.015em; }
h1 { font-size: 2.1rem; margin: 0 0 .75rem; }
h2 { font-size: 1.45rem; margin: 2.25rem 0 .75rem; }
h3 { font-size: 1.1rem; margin: 1.5rem 0 .5rem; }
code { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: .88em; background: var(--surface-2); padding: .1em .35em; border-radius: 5px; }
pre { background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; padding: 1rem; overflow-x: auto; font-size: .88rem; line-height: 1.5; }
pre code { background: none; padding: 0; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 1.25rem; }
.narrow { max-width: 820px; }
.muted { color: var(--text-2); }
.small-text { font-size: .88rem; }
.lead { font-size: 1.15rem; color: var(--text-2); max-width: 46rem; }
/* Header and footer */
.site-header { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--surface) 92%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--border); }
.header-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 64px; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: .6rem; color: var(--text); font-size: 1.1rem; }
.brand:hover { text-decoration: none; }
.brand strong { color: var(--accent); }
.logo { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: linear-gradient(135deg, #0e7490, #155e75); color: #fff; font-weight: 700; font-size: .8rem; }
.nav { display: flex; align-items: center; gap: .25rem; flex-wrap: wrap; }
.nav a { color: var(--text-2); padding: .4rem .7rem; border-radius: 8px; font-size: .95rem; }
.nav a:hover { background: var(--surface-2); text-decoration: none; color: var(--text); }
.nav a.active { color: var(--accent-text); background: var(--accent-soft); }
.nav a.button, .nav a.button:hover { color: #fff; background: var(--accent); }
.site-footer { border-top: 1px solid var(--border); background: var(--surface); margin-top: 4rem; padding: 2rem 0; color: var(--text-2); font-size: .92rem; }
.footer-row { display: flex; justify-content: space-between; gap: 2rem; flex-wrap: wrap; }
.footer-links { display: flex; gap: 1.25rem; flex-wrap: wrap; }
/* Building blocks */
.button { display: inline-flex; align-items: center; gap: .4rem; background: var(--accent); color: #fff; padding: .7rem 1.2rem; border-radius: 10px; font-weight: 600; border: 1px solid transparent; }
.button:hover { text-decoration: none; filter: brightness(1.07); }
.button.secondary { background: var(--surface); color: var(--text); border-color: var(--border); }
.button.small { padding: .4rem .85rem; font-size: .92rem; }
.actions { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: 1.5rem; }
.section { padding: 3.5rem 0; }
.section.alt { background: var(--surface); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.eyebrow { color: var(--accent-text); font-weight: 600; font-size: .85rem; letter-spacing: .06em; text-transform: uppercase; }
.grid { display: grid; gap: 1.25rem; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.35rem 1.4rem; box-shadow: var(--shadow); }
.card h3 { margin-top: 0; }
.card p:last-child { margin-bottom: 0; }
.badge { display: inline-flex; align-items: center; gap: .35rem; padding: .15rem .65rem; border-radius: 999px; font-size: .78rem; font-weight: 600; white-space: nowrap; color: var(--c, var(--info)); background: var(--c-soft, var(--info-soft)); }
.badge.ok { --c: var(--ok); --c-soft: var(--ok-soft); }
.badge.warn { --c: var(--medium); --c-soft: var(--medium-soft); }
.badge.bad { --c: var(--critical); --c-soft: var(--critical-soft); }
.badge.accent { --c: var(--accent-text); --c-soft: var(--accent-soft); }
table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; font-size: .95rem; }
th, td { text-align: left; padding: .65rem .9rem; border-bottom: 1px solid var(--border); vertical-align: top; }
th { background: var(--surface-2); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-2); }
tr:last-child td { border-bottom: none; }
.table-scroll { overflow-x: auto; }
.mono { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: .88rem; }
.nowrap { white-space: nowrap; }
.alert { border: 1px solid var(--medium); background: var(--medium-soft); border-radius: var(--radius); padding: .85rem 1rem; }
/* Front page */
.hero { padding: 4.5rem 0 3.5rem; background: radial-gradient(1200px 400px at 80% -10%, var(--accent-soft), transparent 70%); }
.hero h1 { font-size: clamp(2.2rem, 4.5vw, 3.3rem); max-width: 18ch; }
.hero .lead { font-size: 1.25rem; }
.finding-demo { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 12px 32px rgba(16, 24, 40, .08); padding: 1.3rem 1.4rem; font-size: .95rem; }
.finding-demo h3 { margin: .35rem 0 .5rem; font-size: 1.05rem; }
.finding-demo dl { display: grid; grid-template-columns: max-content 1fr; gap: .2rem 1rem; margin: .75rem 0; font-size: .9rem; }
.finding-demo dt { color: var(--text-2); }
.finding-demo dd { margin: 0; font-family: ui-monospace, Consolas, monospace; }
.hero-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 3rem; align-items: center; }
@media (max-width: 900px) { .hero-grid { grid-template-columns: 1fr; } }
.stats { display: flex; gap: 1.75rem; flex-wrap: wrap; margin-top: 2rem; }
.stats > div { min-width: 0; }
.stats strong { display: block; font-size: 1.8rem; color: var(--text); }
.stats span { color: var(--text-2); font-size: .92rem; }
.topics { list-style: none; padding: 0; margin: 0; }
.topics li { display: flex; justify-content: space-between; gap: 1rem; padding: .7rem 0; border-bottom: 1px solid var(--border); }
.topics li:last-child { border-bottom: none; }
/* Plans */
.plans { display: grid; gap: 1.25rem; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.plan { display: flex; flex-direction: column; }
.plan.featured { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.plan .price { font-size: 1.5rem; font-weight: 700; margin: .25rem 0 1rem; }
.plan ul { padding-left: 1.1rem; margin: 0 0 1.25rem; flex: 1; }
/* Documentation */
.doc-layout { display: grid; grid-template-columns: 240px 1fr; gap: 3rem; padding: 2.5rem 0; }
@media (max-width: 860px) { .doc-layout { grid-template-columns: 1fr; } }
.doc-nav { position: sticky; top: 84px; align-self: start; display: flex; flex-direction: column; gap: .15rem; font-size: .95rem; }
.doc-nav a { color: var(--text-2); padding: .35rem .6rem; border-radius: 8px; }
.doc-nav a.active { background: var(--accent-soft); color: var(--accent-text); }
.doc { max-width: 780px; min-width: 0; }
.doc table { font-size: .9rem; display: block; overflow-x: auto; }
.doc blockquote { margin: 1rem 0; padding: .5rem 1rem; border-left: 4px solid var(--accent); background: var(--accent-soft); border-radius: 0 8px 8px 0; }
.page { padding: 2.5rem 0; }
.mt { margin-top: 2rem; }
.card-link { color: var(--text); display: block; } .card-link:hover { text-decoration: none; border-color: var(--accent); } .flush { margin-top: 0; }
.hash { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: .74rem; color: var(--text-2); word-break: break-all; margin-top: .2rem; }

View file

@ -1,4 +1,5 @@
@inherits LayoutComponentBase
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
<div class="shell">
<aside class="sidebar">
@ -15,15 +16,15 @@
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
<span class="nav-label">Community</span>
<a href="https://git.seosoft.dk/seo/sqlinsight-pro" rel="noopener"><Icon Name="users" /> Projekt og forum</a>
<a href="https://git.seosoft.dk/seo/sqlinsight-pro/src/branch/main/docs" rel="noopener"><Icon Name="book" /> Dokumentation</a>
<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" />
<strong>Brug for en ekspert?</strong>
<p>Professional og Enterprise giver prioriteret mail- og telefonsupport fra erfarne SQL Server-folk.</p>
<a href="https://git.seosoft.dk/seo/sqlinsight-pro#forretningsmodel" rel="noopener">Se supportaftaler</a>
<a href="@(new Uri(Options.Value.CommunityUrl, "support"))" rel="noopener">Se supportaftaler</a>
</div>
</aside>

View file

@ -23,4 +23,10 @@ public sealed class WebOptions
/// <summary>Name shown on reports, e.g. the company or department.</summary>
public string Organization { get; set; } = "SQL Server-miljøet";
/// <summary>The SQLInsight Pro website (documentation, downloads, support plans), linked from the menu.</summary>
public Uri CommunityUrl { get; set; } = new("https://sqlinsight.seosoft.dk/");
/// <summary>The community forum, linked from the menu.</summary>
public Uri ForumUrl { get; set; } = new("https://forum.sqlinsight.seosoft.dk/");
}

View file

@ -0,0 +1,30 @@
using SQLInsight.Community.Services;
namespace SQLInsight.Community.Tests;
public sealed class DocsTests
{
private const string Source = "https://git.example/seo/sqlinsight-pro";
[Theory]
[InlineData("opdatering.md", "/dokumentation/opdatering")]
[InlineData("installation.md#msi", "/dokumentation/installation#msi")]
[InlineData("#afsnit", "#afsnit")]
[InlineData("https://learn.microsoft.com/sql", "https://learn.microsoft.com/sql")]
[InlineData("../db/permissions/grant-least-privilege.sql", Source + "/src/branch/main/db/permissions/grant-least-privilege.sql")]
[InlineData("images/x.png", Source + "/src/branch/main/docs/images/x.png")]
[InlineData("../README.md#status", Source + "/src/branch/main/README.md#status")]
public void Links_are_rewritten(string url, string expected) => Assert.Equal(expected, DocsService.Rewrite(url, Source));
[Fact]
public void Markdown_html_is_not_rendered_and_title_is_taken_from_h1()
{
var page = DocsService.Render("x", "resumé", "# Titel\n\nTekst <script>alert(1)</script> [link](regler.md)\n\n## Afsnit A\n", Source);
Assert.Equal("Titel", page.Title);
Assert.DoesNotContain("<script>", page.Html, StringComparison.Ordinal);
Assert.DoesNotContain("<h1", page.Html, StringComparison.Ordinal);
Assert.Contains("href=\"/dokumentation/regler\"", page.Html, StringComparison.Ordinal);
Assert.Contains("id=\"afsnit-a\"", page.Html, StringComparison.Ordinal);
}
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Community\SQLInsight.Community.csproj" />
</ItemGroup>
<ItemGroup>
<!-- The signed test feed from the update tests: a throwaway key, releases and rule packs in a folder. -->
<Compile Include="..\SQLInsight.Updates.Tests\TestFeed.cs" Link="TestFeed.cs" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,237 @@
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using SQLInsight.Updates;
using SQLInsight.Updates.Tests;
namespace SQLInsight.Community.Tests;
/// <summary>The site against a signed test feed (a folder) and a stubbed Discourse forum.</summary>
public sealed class SiteTests : IDisposable
{
private const string LatestJson = """
{ "topic_list": { "topics": [
{ "id": 1, "title": "Velkommen", "slug": "velkommen", "posts_count": 1, "bumped_at": "2026-09-01T10:00:00Z", "pinned": true },
{ "id": 7, "title": "Hvad skal MAXDOP være?", "slug": "maxdop", "posts_count": 3, "bumped_at": "2026-09-28T10:00:00Z", "pinned": false, "has_accepted_answer": true }
] } }
""";
private readonly TestFeed _feed = new();
private readonly StubHandler _forum = new(LatestJson);
public void Dispose()
{
_feed.Dispose();
_forum.Dispose();
}
[Theory]
[InlineData("/")]
[InlineData("/funktioner")]
[InlineData("/download")]
[InlineData("/dokumentation")]
[InlineData("/dokumentation/installation")]
[InlineData("/sql-server-versioner")]
[InlineData("/regler")]
[InlineData("/support")]
[InlineData("/health")]
public async Task Pages_render(string path)
{
using var factory = Site();
using var client = factory.CreateClient();
using var response = await client.GetAsync(new Uri(path, UriKind.Relative), CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single(), StringComparison.Ordinal);
}
[Theory]
[InlineData("/findes-ikke")]
[InlineData("/dokumentation/findes-ikke")]
[InlineData("/dokumentation/hub")]
public async Task Unknown_pages_are_404(string path)
{
using var factory = Site();
using var client = factory.CreateClient();
using var response = await client.GetAsync(new Uri(path, UriKind.Relative), CancellationToken.None);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Contains("findes ikke", await response.Content.ReadAsStringAsync(CancellationToken.None), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Download_lists_signed_releases_with_hashes()
{
var release = _feed.MsiRelease("1.2.0");
_feed.Publish("stable", release: release);
using var factory = Site();
var html = await Get(factory, "/download");
Assert.Contains("SQLInsight Pro 1.2.0", html, StringComparison.Ordinal);
Assert.Contains("SQLInsight-Pro-1.2.0-x64.msi", html, StringComparison.Ordinal);
foreach (var asset in release.Assets)
{
Assert.Contains(asset.File.Sha256.ToLowerInvariant(), html, StringComparison.Ordinal);
}
}
[Fact]
public async Task A_manifest_with_a_bad_signature_is_not_shown()
{
_feed.Publish("stable", release: _feed.MsiRelease("1.2.0"));
var path = Path.Combine(_feed.FeedDirectory, "stable", UpdateFeed.ManifestFile);
var token = await File.ReadAllTextAsync(path, CancellationToken.None);
var parts = token.Split('.');
var payload = parts[1].ToCharArray();
payload[payload.Length / 2] = payload[payload.Length / 2] == 'A' ? 'B' : 'A';
parts[1] = new string(payload);
await File.WriteAllTextAsync(path, string.Join('.', parts), CancellationToken.None);
using var factory = Site();
var html = await Get(factory, "/download");
Assert.DoesNotContain("1.2.0", html, StringComparison.Ordinal);
Assert.Contains("ingen udgivelser", html, StringComparison.Ordinal);
}
[Fact]
public async Task A_manifest_signed_with_an_unknown_key_is_not_shown()
{
_feed.Publish("stable", release: _feed.MsiRelease("1.2.0"));
using var factory = Site(trustKey: false);
var html = await Get(factory, "/download");
Assert.DoesNotContain("SQLInsight Pro 1.2.0", html, StringComparison.Ordinal);
}
[Fact]
public async Task Front_page_shows_forum_topics_without_pinned_ones()
{
using var factory = Site();
var html = await Get(factory, "/");
Assert.Contains("Hvad skal MAXDOP være?", html, StringComparison.Ordinal);
Assert.Contains("https://forum.example/t/maxdop/7", html, StringComparison.Ordinal);
Assert.Contains("Løst", html, StringComparison.Ordinal);
Assert.DoesNotContain(">Velkommen<", html, StringComparison.Ordinal);
Assert.EndsWith("/latest.json", _forum.Requests.Single().AbsolutePath, StringComparison.Ordinal);
}
[Fact]
public async Task Front_page_works_when_the_forum_is_down()
{
_forum.Fail = true;
using var factory = Site();
var html = await Get(factory, "/");
Assert.Contains("SQL Server-overvågning", html, StringComparison.Ordinal);
}
[Fact]
public async Task Versions_page_uses_the_newest_signed_rule_pack()
{
_feed.Publish("stable", rulePack: _feed.RulePack(new Version(2099, 1, 1)));
using var factory = Site();
var html = await Get(factory, "/sql-server-versioner");
Assert.Contains("2099.1.1", html, StringComparison.Ordinal);
}
[Fact]
public async Task Docs_are_rendered_with_rewritten_links()
{
using var factory = Site();
var html = await Get(factory, "/dokumentation/installation");
Assert.Contains("href=\"/dokumentation/opdatering", html, StringComparison.Ordinal);
Assert.Contains("https://git.example/seo/sqlinsight-pro/src/branch/main/db/permissions/grant-least-privilege.sql", html, StringComparison.Ordinal);
}
[Fact]
public async Task Rules_page_lists_built_in_and_community_rules()
{
using var factory = Site();
var html = await Get(factory, "/regler");
Assert.Contains("SQLI-CFG-004", html, StringComparison.Ordinal);
Assert.Contains("COM-SEC-001", html, StringComparison.Ordinal);
}
[Fact]
public async Task Forum_shortcut_redirects_to_the_forum()
{
using var factory = Site();
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
using var response = await client.GetAsync(new Uri("/forum", UriKind.Relative), CancellationToken.None);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal(new Uri("https://forum.example/"), response.Headers.Location);
}
private WebApplicationFactory<Program> Site(bool trustKey = true)
{
var settings = new Dictionary<string, string?>
{
["SQLInsight:Community:Feed"] = _feed.FeedDirectory,
["SQLInsight:Community:ForumUrl"] = "https://forum.example/",
["SQLInsight:Community:SourceUrl"] = "https://git.example/seo/sqlinsight-pro",
};
if (trustKey)
{
settings[$"SQLInsight:Community:TrustedKeys:{TestFeed.KeyId}"] = _feed.PublicKey;
}
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Production");
foreach (var (key, value) in settings)
{
builder.UseSetting(key, value);
}
builder.ConfigureTestServices(services =>
services.AddHttpClient("forum").ConfigurePrimaryHttpMessageHandler(() => new NonDisposing(_forum)));
});
}
private static async Task<string> Get(WebApplicationFactory<Program> factory, string path)
{
using var client = factory.CreateClient();
return await client.GetStringAsync(new Uri(path, UriKind.Relative), CancellationToken.None);
}
private sealed class StubHandler(string body) : HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
public bool Fail { get; set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri!);
return Fail
? throw new HttpRequestException("forum nede")
: Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") });
}
public Task<HttpResponseMessage> Forward(HttpRequestMessage request, CancellationToken cancellationToken) => SendAsync(request, cancellationToken);
}
/// <summary>Lets the handler factory dispose its wrapper without disposing the shared stub.</summary>
private sealed class NonDisposing(StubHandler inner) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => inner.Forward(request, cancellationToken);
}
}

View file

@ -28,6 +28,9 @@ public sealed class TestFeed : IDisposable
public TrustedKeys Keys { get; }
public UpdateFeed Feed { get; }
/// <summary>The throwaway key's public half (base64 SubjectPublicKeyInfo), as configured in TrustedKeys.</summary>
public string PublicKey => Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo());
public FileRef AddFile(string path, byte[] bytes)
{
var full = Path.Combine(FeedDirectory, path);

View file

@ -132,7 +132,7 @@ int Publish()
{
if (args[i] == "--asset" && args[i + 1].Split('=', 2) is [var platform, var file])
{
assets.Add(new ReleaseAsset(platform, AddFile(feed, "releases", $"sqlinsight-{version}-{platform}{Path.GetExtension(file).ToLowerInvariant()}", File.ReadAllBytes(file))));
assets.Add(new ReleaseAsset(platform, AddFile(feed, "releases", $"sqlinsight-{version}-{platform.Replace("-msi", "", StringComparison.Ordinal)}{Path.GetExtension(file).ToLowerInvariant()}", File.ReadAllBytes(file))));
}
}