diff --git a/Directory.Packages.props b/Directory.Packages.props index 2332d02..67c25eb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,7 @@ + diff --git a/README.md b/README.md index 60501e3..e9d627f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SQLInsight.slnx b/SQLInsight.slnx index 5bcd0b0..9466406 100644 --- a/SQLInsight.slnx +++ b/SQLInsight.slnx @@ -3,6 +3,7 @@ + @@ -17,6 +18,7 @@ + diff --git a/deploy/community/Caddyfile b/deploy/community/Caddyfile new file mode 100644 index 0000000..57773c6 --- /dev/null +++ b/deploy/community/Caddyfile @@ -0,0 +1,5 @@ +{$COMMUNITY_HOST} { + encode zstd gzip + reverse_proxy community:8080 + header Strict-Transport-Security "max-age=31536000" +} diff --git a/deploy/community/Dockerfile b/deploy/community/Dockerfile new file mode 100644 index 0000000..054bd8c --- /dev/null +++ b/deploy/community/Dockerfile @@ -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"] diff --git a/deploy/community/docker-compose.yml b/deploy/community/docker-compose.yml new file mode 100644 index 0000000..aca71c9 --- /dev/null +++ b/deploy/community/docker-compose.yml @@ -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: diff --git a/deploy/forum/app.yml b/deploy/forum/app.yml new file mode 100644 index 0000000..a84794b --- /dev/null +++ b/deploy/forum/app.yml @@ -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" diff --git a/deploy/forum/configure.py b/deploy/forum/configure.py new file mode 100755 index 0000000..6922418 --- /dev/null +++ b/deploy/forum/configure.py @@ -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() diff --git a/deploy/forum/install.sh b/deploy/forum/install.sh new file mode 100755 index 0000000..ad34ab5 --- /dev/null +++ b/deploy/forum/install.sh @@ -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 < CONTACT_EMAIL= python3 $HERE/configure.py + 4. Slet API-nøglen igen under Admin → API, når opsætningen er færdig. +NEXT diff --git a/docs/community.md b/docs/community.md new file mode 100644 index 0000000..0766e7b --- /dev/null +++ b/docs/community.md @@ -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:///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:` | (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. diff --git a/src/SQLInsight.Community/CommunityOptions.cs b/src/SQLInsight.Community/CommunityOptions.cs new file mode 100644 index 0000000..b260bb3 --- /dev/null +++ b/src/SQLInsight.Community/CommunityOptions.cs @@ -0,0 +1,23 @@ +namespace SQLInsight.Community; + +public sealed class CommunityOptions +{ + public const string SectionName = "SQLInsight:Community"; + + /// The signed update feed (https or folder), where releases and rule packs are published. + public string? Feed { get; set; } + + /// The Discourse forum, e.g. https://forum.sqlinsight.seosoft.dk/. + public Uri? ForumUrl { get; set; } + + public Uri SourceUrl { get; set; } = new("https://git.seosoft.dk/seo/sqlinsight-pro"); + + /// Where Professional and Enterprise enquiries go. + public string ContactEmail { get; set; } = "salg@example.com"; + + /// Public keys trusted for the feed, in addition to the embedded ones (key id → base64 SubjectPublicKeyInfo). + public IDictionary TrustedKeys { get; } = new Dictionary(); + + /// How long releases, the rule pack and forum topics are cached. + public TimeSpan CacheFor { get; set; } = TimeSpan.FromMinutes(10); +} diff --git a/src/SQLInsight.Community/Components/App.razor b/src/SQLInsight.Community/Components/App.razor new file mode 100644 index 0000000..39d1d48 --- /dev/null +++ b/src/SQLInsight.Community/Components/App.razor @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/SQLInsight.Community/Components/Layout/SiteLayout.razor b/src/SQLInsight.Community/Components/Layout/SiteLayout.razor new file mode 100644 index 0000000..44098a0 --- /dev/null +++ b/src/SQLInsight.Community/Components/Layout/SiteLayout.razor @@ -0,0 +1,40 @@ +@inherits LayoutComponentBase +@inject IOptions Options + + +
+ @Body +
+ diff --git a/src/SQLInsight.Community/Components/Pages/Documentation.razor b/src/SQLInsight.Community/Components/Pages/Documentation.razor new file mode 100644 index 0000000..be2a99b --- /dev/null +++ b/src/SQLInsight.Community/Components/Pages/Documentation.razor @@ -0,0 +1,57 @@ +@page "/dokumentation" +@page "/dokumentation/{Slug}" +@inject DocsService Docs +@inject NavigationManager Navigation + +@if (_page is null && Slug is not null) +{ + return; +} + +@(_page?.Title ?? "Dokumentation") · SQLInsight Pro + +
+ +
+ @if (_page is null) + { +

Dokumentation

+

Alt fra installation til egne regler. Samme tekster som i kildekoden, så de altid passer til den nyeste version.

+
+ @foreach (var doc in Docs.All) + { + +

@doc.Title

+

@doc.Summary

+
+ } +
+ } + else + { +

@_page.Title

+ @((MarkupString)_page.Html) + } +
+
+ +@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(); + } + } +} diff --git a/src/SQLInsight.Community/Components/Pages/Download.razor b/src/SQLInsight.Community/Components/Pages/Download.razor new file mode 100644 index 0000000..fb35582 --- /dev/null +++ b/src/SQLInsight.Community/Components/Pages/Download.razor @@ -0,0 +1,95 @@ +@page "/download" +@using SQLInsight.Updates +@inject ReleaseService Releases +@inject IOptions Options + +Download · SQLInsight Pro + +
+

Download

+

Gratis, også til produktion. Alle filer er signeret af SEOSoft, og SHA-256 står ved hver fil.

+ + @if (_state.Channels.Count == 0 || _state.Channels.All(c => c.Manifest.Release is null)) + { +
Der er ingen udgivelser endnu. Byg fra kildekoden på Forgejo, eller se installationsvejledningen.
+ } + + @foreach (var channel in _state.Channels.Where(c => c.Manifest.Release is not null)) + { + var release = channel.Manifest.Release!; +
+

SQLInsight Pro @release.Version + @(channel.Channel == UpdateManifest.StableChannel ? "Stabil" : "Preview") + @if (release.Security) { Sikkerhedsopdatering } +

+

Udgivet @release.Released.ToString("d. MMMM yyyy", Danish)@(release.NotesUrl is { } notes ? "" : ".") + @if (release.NotesUrl is { } url) { · Hvad er nyt } +

+
+ + + + @foreach (var asset in release.Assets.OrderBy(a => Order(a.Platform))) + { + + + + + + } + +
FilTilStørrelse
+ @if (Releases.DownloadUrl(asset.File) is { } link) + { + @System.IO.Path.GetFileName(asset.File.Path) + } + else + { + @System.IO.Path.GetFileName(asset.File.Path) + } +
SHA-256 @asset.File.Sha256.ToLowerInvariant()
+
@Describe(asset.Platform)@Size(asset.File.Size)
+
+
+ } + +

Kom i gang

+
    +
  1. Opret en tom database til SQLInsight på en SQL Server 2016 eller nyere.
  2. +
  3. Giv agentens konto læserettigheder på de instanser, der skal overvåges, med scriptet grant-least-privilege.sql.
  4. +
  5. Kør MSI'en, og angiv forbindelsesstrengen og den første server. Eller installer stille:
  6. +
+
msiexec /i @MsiName /qn /l*v install.log `
+  REPOSITORY_CONNECTION="Server=sqlmon01;Database=SQLInsight;Integrated Security=true;Encrypt=true" `
+  MONITOR_SERVER=sql01
+

Åbn derefter http://serveren:5080. Hele vejledningen: Installation.

+

Kildekoden er under MIT-licens på Forgejo.

+
+ +@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"; +} diff --git a/src/SQLInsight.Community/Components/Pages/Error.razor b/src/SQLInsight.Community/Components/Pages/Error.razor new file mode 100644 index 0000000..d02a3bf --- /dev/null +++ b/src/SQLInsight.Community/Components/Pages/Error.razor @@ -0,0 +1,7 @@ +@page "/fejl" + +Fejl · SQLInsight Pro +
+

Noget gik galt

+

Prøv igen om lidt. Fortsætter det, så skriv i forummet.

+
diff --git a/src/SQLInsight.Community/Components/Pages/Features.razor b/src/SQLInsight.Community/Components/Pages/Features.razor new file mode 100644 index 0000000..6e3cce1 --- /dev/null +++ b/src/SQLInsight.Community/Components/Pages/Features.razor @@ -0,0 +1,48 @@ +@page "/funktioner" + +Funktioner · SQLInsight Pro + +
+

Hvad SQLInsight Pro finder

+

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.

+ +
+
+

Performance

+

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.

+
+
+

Indexes og statistik

+

Manglende indexes vurderet mod de eksisterende, ubrugte og duplikerede indexes, heaps med forwarded records og forældede statistikker.

+
+
+

Drift og datasikkerhed

+

Backup-dækning og log-backups, DBCC CHECKDB, korrupte sider, Always On-sundhed og datatab, SQL Agent-jobs, der fejler.

+
+
+

Konfiguration

+

MAXDOP efter NUMA-layout, cost threshold, max server memory, tempdb, VLF'er, autogrowth, compatibility level og trace flags.

+
+
+

Versioner og opdateringer

+

Advarer, når en instans mangler CU'er eller sikkerhedsopdateringer, og når versionen er på vej ud af Microsofts support.

+
+
+

Afvigelser fra det normale

+

Baselines pr. time på ugen, så usædvanlig CPU, trafik der falder bort eller page life expectancy, der styrtdykker, bliver opdaget.

+
+
+ +

Og alt det omkring

+
+

Notifikationer

E-mail, Microsoft Teams, webhook og Windows' hændelseslog (SCOM).

+

Integration

Prometheus og Grafana-dashboard, JSON-API.

+

Rapporter

Driftsrapport som PDF til ledelse og revision, også som månedlig mail.

+

Egne regler

Skriv regler i SQL, eller brug community-reglerne. Se alle regler.

+
+ + +
diff --git a/src/SQLInsight.Community/Components/Pages/Home.razor b/src/SQLInsight.Community/Components/Pages/Home.razor new file mode 100644 index 0000000..d9d5fa3 --- /dev/null +++ b/src/SQLInsight.Community/Components/Pages/Home.razor @@ -0,0 +1,124 @@ +@page "/" +@inject ReleaseService Releases +@inject ForumService Forum +@inject IOptions Options + +SQLInsight Pro · Gratis SQL Server-overvågning med dyb ekspertise + +
+
+
+
Gratis · open source · on-prem
+

SQL Server-overvågning, der forklarer hvad du skal gøre

+

+ SQLInsight Pro finder problemerne i jeres SQL Servere og siger, hvorfor de er et problem, + hvad målingerne viser, og hvordan I løser dem. Fra SQL Server 2000 til 2025 og Azure SQL Managed Instance. +

+ +
+
@_rulesregler med forklaring
+
2000–2025alle versioner
+
0 kr.for hele produktet
+
+
+
+ Kritisk +

Databasen Økonomi har ikke haft en fuld backup i 9 dage

+

Uden en nylig fuld backup kan databasen ikke gendannes til et kendt punkt, og log-backups kan ikke bruges alene.

+
+
Seneste fulde backup
2026-09-20 22:04
+
Recovery model
FULL
+
Størrelse
184 GB
+
+

Anbefaling: Tag en fuld backup nu, og kontrollér det job, der skulle have taget den. SQLInsight foreslår scriptet; det køres aldrig automatisk.

+
+
+
+ +
+
+
+
+

Dyb ekspertise, ikke bare grafer

+

Wait stats, Query Store, planer, indexes, backup, CHECKDB, Always On, konfiguration og versioner. Hvert fund har evidens, forklaring og en konkret anbefaling.

+
+
+

Kører hos jer

+

Agent, database og webbrugerflade installeres on-prem med én MSI. Der sendes intet ud af huset, med mindre I selv slår det til.

+
+
+

Altid opdateret

+

Nye CU'er og supportdatoer kommer som signerede regelpakker, uden at programmet skal opdateres. Selve programmet kan opdatere sig selv.

+
+
+

Hjælp, når I har brug for det

+

Spørg i forummet, eller få prioriteret mail- og telefonsupport fra erfarne SQL Server-folk med Professional og Enterprise.

+
+
+
+
+ +
+
+
+

Seneste fra forummet

+ @if (_topics.Count == 0) + { +

Stil spørgsmål, del erfaringer og foreslå nye regler i forummet.

+ } + else + { +
    + @foreach (var topic in _topics) + { +
  • + @topic.Title + @(topic.Solved ? "Løst · " : "")@topic.Replies svar +
  • + } +
+ } + @if (Options.Value.ForumUrl is { } forum) + { +

Gå til forummet →

+ } +
+
+

Nyeste SQL Server-opdateringer

+
    + @foreach (var build in _builds) + { +
  • + @build.Release.DisplayName() @build.Name + @build.Released.ToString("d. MMM yyyy", Danish) +
  • + } +
+

Alle versioner og supportdatoer →

+
+
+
+ +@code { + private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK"); + + private IReadOnlyList _topics = []; + private IReadOnlyList _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() + .Select(feed.RulePack.Builds.Latest) + .OfType() + .OrderByDescending(b => b.Released).ThenByDescending(b => b.Release) + .Take(5) + .ToList(); + _topics = await Forum.LatestAsync(6, CancellationToken.None); + } +} diff --git a/src/SQLInsight.Community/Components/Pages/NotFound.razor b/src/SQLInsight.Community/Components/Pages/NotFound.razor new file mode 100644 index 0000000..60a9f6f --- /dev/null +++ b/src/SQLInsight.Community/Components/Pages/NotFound.razor @@ -0,0 +1,7 @@ +@page "/ikke-fundet" + +Ikke fundet · SQLInsight Pro +
+

Siden findes ikke

+

Prøv dokumentationen, eller gå til forsiden.

+
diff --git a/src/SQLInsight.Community/Components/Pages/Rules.razor b/src/SQLInsight.Community/Components/Pages/Rules.razor new file mode 100644 index 0000000..a40b72b --- /dev/null +++ b/src/SQLInsight.Community/Components/Pages/Rules.razor @@ -0,0 +1,35 @@ +@page "/regler" +@inject ReleaseService Releases + +Regler · SQLInsight Pro + +
+

@_rules.Count regler

+

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".

+

Skriv dine egne regler i SQL, eller bidrag med en community-regel →

+ + @foreach (var area in _rules.GroupBy(r => r.Area).OrderBy(g => g.Key, StringComparer.Create(CultureInfo.GetCultureInfo("da-DK"), false))) + { +

@area.Key (@area.Count())

+
+ + + @foreach (var rule in area.OrderBy(r => r.Id, StringComparer.Ordinal)) + { + + + + + + } + +
@rule.Id@rule.Title@if (rule.Community) { Community@(rule.Author is { } a ? $" · {a}" : "") }
+
+ } +
+ +@code { + private IReadOnlyList _rules = []; + + protected override async Task OnInitializedAsync() => _rules = RuleCatalog.All((await Releases.GetAsync(CancellationToken.None)).RulePack.CommunityRules); +} diff --git a/src/SQLInsight.Community/Components/Pages/SqlServerVersions.razor b/src/SQLInsight.Community/Components/Pages/SqlServerVersions.razor new file mode 100644 index 0000000..ab23674 --- /dev/null +++ b/src/SQLInsight.Community/Components/Pages/SqlServerVersions.razor @@ -0,0 +1,65 @@ +@page "/sql-server-versioner" +@using SQLInsight.Core.Instances +@using SQLInsight.Rules.Lifecycle +@inject ReleaseService Releases +@inject TimeProvider Time + +SQL Server-versioner, seneste CU og supportdatoer · SQLInsight Pro + +
+

SQL Server-versioner

+

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.

+

Regelpakke @_pack.Version · katalog pr. @_pack.Builds.AsOf.ToString("d. MMMM yyyy", Danish). Kilder: Microsoft Lifecycle og Microsoft Learn.

+ +
+ + + + @foreach (var row in _rows) + { + + + + + + + + + + } + +
VersionSeneste opdateringBuildUdgivetAlmindelig support tilUdvidet support tilStatus
@row.Release.DisplayName()@(row.Build is { } b ? $"{b.Name}" : "–")@if (row.Build is { Kb.Length: > 0 } kb) {
@kb.Kb }
@(row.Build?.Build.ToString() ?? "–")@(row.Build?.Released.ToString("d. MMM yyyy", Danish) ?? "–")@Date(row.Lifecycle?.MainstreamEnd)@Date(row.Lifecycle?.ExtendedEnd)@row.Status.Text
+
+

Udvidet support betyder kun sikkerhedsopdateringer. Extended Security Updates (betalt) er ikke medregnet. Azure SQL Managed Instance opdateres af Microsoft og er ikke med.

+
+ +@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 _rows = []; + + protected override async Task OnInitializedAsync() + { + _pack = (await Releases.GetAsync(CancellationToken.None)).RulePack; + var today = DateOnly.FromDateTime(Time.GetUtcNow().UtcDateTime); + _rows = Enum.GetValues() + .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) ?? "–"; +} diff --git a/src/SQLInsight.Community/Components/Pages/Support.razor b/src/SQLInsight.Community/Components/Pages/Support.razor new file mode 100644 index 0000000..68e6df4 --- /dev/null +++ b/src/SQLInsight.Community/Components/Pages/Support.razor @@ -0,0 +1,64 @@ +@page "/support" +@inject IOptions Options + +Support og priser · SQLInsight Pro + +
+

Support og priser

+

Hele programmet er gratis. Betalte aftaler giver adgang til mennesker med SQL Server-erfaring og en svartid, ikke til flere funktioner.

+ +
+
+

Community

+
Gratis
+
    +
  • Al overvågning, analyse og alle regler
  • +
  • Opdateringer af program og regelpakker
  • +
  • Rapporter, API og integrationer
  • +
  • Hjælp i forummet fra fællesskabet
  • +
+ Download +
+ +
+

Enterprise

+
Abonnement
+
    +
  • Alt i Professional
  • +
  • Telefonsupport
  • +
  • Proaktiv opfølgning via SQLInsight Hub
  • +
  • Hjælp til tuning og opgraderinger
  • +
+ Kontakt os +
+
+ +

Hvor får jeg hjælp?

+
+
+

Forummet

+

Spørgsmål om installation, fund og tuning. Andre brugere og SEOSoft svarer; gode svar markeres som løsningen.

+ @if (Options.Value.ForumUrl is { } forum) { Gå til forummet → } +
+
+

Fejl og forslag

+

Har du fundet en fejl, eller har du en idé til en regel? Opret et issue, eller bidrag direkte med en community-regel.

+ Issues på Forgejo → +
+
+

Dokumentation

+

Installation, opdateringer, integration, rapporter og egne regler.

+ Til dokumentationen → +
+
+
diff --git a/src/SQLInsight.Community/Components/Routes.razor b/src/SQLInsight.Community/Components/Routes.razor new file mode 100644 index 0000000..6fec71d --- /dev/null +++ b/src/SQLInsight.Community/Components/Routes.razor @@ -0,0 +1,5 @@ + + + + + diff --git a/src/SQLInsight.Community/Components/_Imports.razor b/src/SQLInsight.Community/Components/_Imports.razor new file mode 100644 index 0000000..c68cfda --- /dev/null +++ b/src/SQLInsight.Community/Components/_Imports.razor @@ -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 diff --git a/src/SQLInsight.Community/Program.cs b/src/SQLInsight.Community/Program.cs new file mode 100644 index 0000000..5a4461d --- /dev/null +++ b/src/SQLInsight.Community/Program.cs @@ -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(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(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +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 options) => + options.Value.ForumUrl is { } forum ? Results.Redirect(forum.ToString()) : Results.NotFound()); +app.MapRazorComponents(); +app.Run(); + +/// Entry point, visible to integration tests. +public partial class Program; diff --git a/src/SQLInsight.Community/SQLInsight.Community.csproj b/src/SQLInsight.Community/SQLInsight.Community.csproj new file mode 100644 index 0000000..77c376b --- /dev/null +++ b/src/SQLInsight.Community/SQLInsight.Community.csproj @@ -0,0 +1,18 @@ + + + SQLInsight.Community + + + + + + + + + + + + + + + diff --git a/src/SQLInsight.Community/Services/DocsService.cs b/src/SQLInsight.Community/Services/DocsService.cs new file mode 100644 index 0000000..416b8be --- /dev/null +++ b/src/SQLInsight.Community/Services/DocsService.cs @@ -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); + +/// +/// 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. +/// +public sealed class DocsService +{ + /// Order and one-line summaries for the documentation index. Documents not listed are not published. + 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 _pages; + + public DocsService(IOptions 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 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().FirstOrDefault(h => h.Level == 1) is { Inline: { } inline } + ? string.Concat(inline.Descendants().Select(l => l.Content.ToString())) + : slug; + + foreach (var link in document.Descendants()) + { + 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()); + } + + /// installation.md#x → /dokumentation/installation#x; ../db/x.sql → the file on Forgejo; absolute and anchor links unchanged. + 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}"; + } +} diff --git a/src/SQLInsight.Community/Services/ForumService.cs b/src/SQLInsight.Community/Services/ForumService.cs new file mode 100644 index 0000000..87e4fcf --- /dev/null +++ b/src/SQLInsight.Community/Services/ForumService.cs @@ -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); + +/// +/// 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. +/// +public sealed partial class ForumService(IOptions options, IHttpClientFactory http, TimeProvider time, ILogger logger) : IDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + + public void Dispose() => _gate.Dispose(); + private (IReadOnlyList Topics, DateTimeOffset At)? _cache; + + public async Task> 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> 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(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? 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); +} diff --git a/src/SQLInsight.Community/Services/ReleaseService.cs b/src/SQLInsight.Community/Services/ReleaseService.cs new file mode 100644 index 0000000..a7a941e --- /dev/null +++ b/src/SQLInsight.Community/Services/ReleaseService.cs @@ -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); + +/// What the signed feed offers right now, per channel, plus the newest rule pack. +public sealed record FeedState(IReadOnlyList Channels, RulePack RulePack, string? Error); + +/// +/// 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. +/// +public sealed partial class ReleaseService(IOptions options, IHttpClientFactory http, TimeProvider time, ILogger logger) : IDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + + public void Dispose() => _gate.Dispose(); + private (FeedState State, DateTimeOffset At)? _cache; + + public async Task 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(); + } + } + + /// Where a feed file can be downloaded (feed paths are relative to the feed root). + 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 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(); + var errors = new List(); + 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); +} diff --git a/src/SQLInsight.Community/Services/RuleCatalog.cs b/src/SQLInsight.Community/Services/RuleCatalog.cs new file mode 100644 index 0000000..d8399e9 --- /dev/null +++ b/src/SQLInsight.Community/Services/RuleCatalog.cs @@ -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); + +/// Every rule the product ships: the built-in ones and the community rules in the rule pack. +public static class RuleCatalog +{ + private static readonly Dictionary 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 All(IReadOnlyList community) + { + ArgumentNullException.ThrowIfNull(community); + IEnumerable 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"; + } +} diff --git a/src/SQLInsight.Community/wwwroot/favicon.svg b/src/SQLInsight.Community/wwwroot/favicon.svg new file mode 100644 index 0000000..54199d9 --- /dev/null +++ b/src/SQLInsight.Community/wwwroot/favicon.svg @@ -0,0 +1 @@ +SI diff --git a/src/SQLInsight.Community/wwwroot/fonts/Inter-OFL.txt b/src/SQLInsight.Community/wwwroot/fonts/Inter-OFL.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/src/SQLInsight.Community/wwwroot/fonts/Inter-OFL.txt @@ -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. diff --git a/src/SQLInsight.Community/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 b/src/SQLInsight.Community/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 new file mode 100644 index 0000000..479d010 Binary files /dev/null and b/src/SQLInsight.Community/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 differ diff --git a/src/SQLInsight.Community/wwwroot/fonts/inter-latin-wght-normal.woff2 b/src/SQLInsight.Community/wwwroot/fonts/inter-latin-wght-normal.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/src/SQLInsight.Community/wwwroot/fonts/inter-latin-wght-normal.woff2 differ diff --git a/src/SQLInsight.Community/wwwroot/site.css b/src/SQLInsight.Community/wwwroot/site.css new file mode 100644 index 0000000..bab33f0 --- /dev/null +++ b/src/SQLInsight.Community/wwwroot/site.css @@ -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; } diff --git a/src/SQLInsight.Web/Components/Layout/MainLayout.razor b/src/SQLInsight.Web/Components/Layout/MainLayout.razor index 1eced5d..8055ad5 100644 --- a/src/SQLInsight.Web/Components/Layout/MainLayout.razor +++ b/src/SQLInsight.Web/Components/Layout/MainLayout.razor @@ -1,4 +1,5 @@ @inherits LayoutComponentBase +@inject Microsoft.Extensions.Options.IOptions Options
diff --git a/src/SQLInsight.Web/WebOptions.cs b/src/SQLInsight.Web/WebOptions.cs index b8d2dd2..0905a25 100644 --- a/src/SQLInsight.Web/WebOptions.cs +++ b/src/SQLInsight.Web/WebOptions.cs @@ -23,4 +23,10 @@ public sealed class WebOptions /// Name shown on reports, e.g. the company or department. public string Organization { get; set; } = "SQL Server-miljøet"; + + /// The SQLInsight Pro website (documentation, downloads, support plans), linked from the menu. + public Uri CommunityUrl { get; set; } = new("https://sqlinsight.seosoft.dk/"); + + /// The community forum, linked from the menu. + public Uri ForumUrl { get; set; } = new("https://forum.sqlinsight.seosoft.dk/"); } diff --git a/tests/SQLInsight.Community.Tests/DocsTests.cs b/tests/SQLInsight.Community.Tests/DocsTests.cs new file mode 100644 index 0000000..e0512b5 --- /dev/null +++ b/tests/SQLInsight.Community.Tests/DocsTests.cs @@ -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 [link](regler.md)\n\n## Afsnit A\n", Source); + + Assert.Equal("Titel", page.Title); + Assert.DoesNotContain("