From 43fbe2147492a365c8e3b687f02f1f19db39a2b4 Mon Sep 17 00:00:00 2001
From: Leo McArdle
Date: Tue, 2 Jul 2024 12:53:37 +0100
Subject: [PATCH] feat(observatory): launch HTTP Observatory (#10371)
Introduces the HTTP Observatory on MDN.
Originally part of the Mozilla Observatory standalone tool, the
HTTP Observatory is now available on MDN under the new "Tools" menu.
Backend: https://github.com/mdn/mdn-http-observatory
(MP-556)
---------
Co-authored-by: Andi Pieper
Co-authored-by: Claas Augner
Co-authored-by: Chris Mills
Co-authored-by: Claas Augner <495429+caugner@users.noreply.github.com>
Co-authored-by: Florian Dieminger
---
.github/workflows/prod-build.yml | 3 +
.github/workflows/stage-build.yml | 3 +
.github/workflows/test-build.yml | 3 +
build/spas.ts | 43 +-
.../public/assets/observatory/assessment.svg | 4 +
.../public/assets/observatory/fail-icon.svg | 13 +
.../observatory/landing-illustration.svg | 93 +++
client/public/assets/observatory/lines.svg | 12 +
client/public/assets/observatory/mdn.svg | 8 +
.../public/assets/observatory/pass-icon.svg | 7 +
.../assets/observatory/results-icon.svg | 4 +
client/public/assets/observatory/scanning.svg | 4 +
client/public/assets/observatory/security.svg | 3 +
client/public/assets/observatory/stars.svg | 3 +
.../assets/observatory/summary-icon.svg | 4 +
.../assets/observatory/tooltip-arrow.svg | 5 +
client/src/app.tsx | 18 +-
client/src/assets/icons/message-question.svg | 5 +
client/src/env.ts | 4 +
client/src/homepage/static-page/index.tsx | 33 +-
.../observatory/benchmark-chart/index.scss | 61 ++
.../src/observatory/benchmark-chart/index.tsx | 253 ++++++++
client/src/observatory/docs/index.scss | 28 +
client/src/observatory/docs/index.tsx | 217 +++++++
client/src/observatory/index.scss | 138 +++++
client/src/observatory/index.tsx | 21 +
client/src/observatory/landing.scss | 233 ++++++++
client/src/observatory/landing.tsx | 208 +++++++
client/src/observatory/layout.tsx | 35 ++
client/src/observatory/progress/index.scss | 34 ++
client/src/observatory/progress/index.tsx | 25 +
client/src/observatory/results.scss | 540 ++++++++++++++++++
client/src/observatory/results.tsx | 233 ++++++++
client/src/observatory/results/benchmark.tsx | 71 +++
client/src/observatory/results/cookies.tsx | 117 ++++
client/src/observatory/results/csp.tsx | 107 ++++
client/src/observatory/results/headers.tsx | 63 ++
client/src/observatory/results/history.tsx | 28 +
.../observatory/results/human-duration.tsx | 51 ++
client/src/observatory/results/rating.tsx | 171 ++++++
.../src/observatory/results/rescan-button.tsx | 57 ++
client/src/observatory/results/tests.tsx | 110 ++++
client/src/observatory/tooltip/index.scss | 78 +++
client/src/observatory/tooltip/index.tsx | 55 ++
client/src/observatory/types.ts | 124 ++++
client/src/observatory/utils.tsx | 139 +++++
client/src/placement-context.tsx | 3 +-
client/src/plus/ai-help/banners.tsx | 1 -
client/src/telemetry/constants.ts | 1 +
client/src/ui/base/_themes.scss | 62 ++
client/src/ui/molecules/main-menu/index.tsx | 15 +-
client/src/ui/molecules/menu/index.tsx | 5 +-
client/src/ui/molecules/plus-menu/index.tsx | 2 +-
client/src/ui/molecules/submenu/index.tsx | 3 +-
client/src/ui/molecules/tools-menu/index.scss | 11 +
client/src/ui/molecules/tools-menu/index.tsx | 45 ++
.../organisms/top-navigation-main/index.scss | 1 -
copy/observatory/faq.md | 138 +++++
copy/observatory/tests_and_scoring.md | 60 ++
docs/envvars.md | 6 +
kumascript/macros/HTTPSidebar.ejs | 4 +-
libs/constants/index.d.ts | 2 +
libs/constants/index.js | 6 +
libs/types/hydration.ts | 1 +
ssr/render.ts | 2 +-
65 files changed, 3805 insertions(+), 32 deletions(-)
create mode 100644 client/public/assets/observatory/assessment.svg
create mode 100644 client/public/assets/observatory/fail-icon.svg
create mode 100644 client/public/assets/observatory/landing-illustration.svg
create mode 100644 client/public/assets/observatory/lines.svg
create mode 100644 client/public/assets/observatory/mdn.svg
create mode 100644 client/public/assets/observatory/pass-icon.svg
create mode 100644 client/public/assets/observatory/results-icon.svg
create mode 100644 client/public/assets/observatory/scanning.svg
create mode 100644 client/public/assets/observatory/security.svg
create mode 100644 client/public/assets/observatory/stars.svg
create mode 100644 client/public/assets/observatory/summary-icon.svg
create mode 100644 client/public/assets/observatory/tooltip-arrow.svg
create mode 100644 client/src/assets/icons/message-question.svg
create mode 100644 client/src/observatory/benchmark-chart/index.scss
create mode 100644 client/src/observatory/benchmark-chart/index.tsx
create mode 100644 client/src/observatory/docs/index.scss
create mode 100644 client/src/observatory/docs/index.tsx
create mode 100644 client/src/observatory/index.scss
create mode 100644 client/src/observatory/index.tsx
create mode 100644 client/src/observatory/landing.scss
create mode 100644 client/src/observatory/landing.tsx
create mode 100644 client/src/observatory/layout.tsx
create mode 100644 client/src/observatory/progress/index.scss
create mode 100644 client/src/observatory/progress/index.tsx
create mode 100644 client/src/observatory/results.scss
create mode 100644 client/src/observatory/results.tsx
create mode 100644 client/src/observatory/results/benchmark.tsx
create mode 100644 client/src/observatory/results/cookies.tsx
create mode 100644 client/src/observatory/results/csp.tsx
create mode 100644 client/src/observatory/results/headers.tsx
create mode 100644 client/src/observatory/results/history.tsx
create mode 100644 client/src/observatory/results/human-duration.tsx
create mode 100644 client/src/observatory/results/rating.tsx
create mode 100644 client/src/observatory/results/rescan-button.tsx
create mode 100644 client/src/observatory/results/tests.tsx
create mode 100644 client/src/observatory/tooltip/index.scss
create mode 100644 client/src/observatory/tooltip/index.tsx
create mode 100644 client/src/observatory/types.ts
create mode 100644 client/src/observatory/utils.tsx
create mode 100644 client/src/ui/molecules/tools-menu/index.scss
create mode 100644 client/src/ui/molecules/tools-menu/index.tsx
create mode 100644 copy/observatory/faq.md
create mode 100644 copy/observatory/tests_and_scoring.md
diff --git a/.github/workflows/prod-build.yml b/.github/workflows/prod-build.yml
index 19210702d04e..b2134db8c9e1 100644
--- a/.github/workflows/prod-build.yml
+++ b/.github/workflows/prod-build.yml
@@ -243,6 +243,9 @@ jobs:
# Playground
REACT_APP_PLAYGROUND_BASE_HOST: mdnplay.dev
+ # Observatory
+ REACT_APP_OBSERVATORY_API_URL: https://observatory-api.mdn.mozilla.net
+
# Sentry.
SENTRY_DSN_BUILD: ${{ secrets.SENTRY_DSN_BUILD }}
SENTRY_ENVIRONMENT: prod
diff --git a/.github/workflows/stage-build.yml b/.github/workflows/stage-build.yml
index c49aa7009736..2e513a546a5b 100644
--- a/.github/workflows/stage-build.yml
+++ b/.github/workflows/stage-build.yml
@@ -260,6 +260,9 @@ jobs:
# Playground
REACT_APP_PLAYGROUND_BASE_HOST: mdnyalp.dev
+ # Observatory
+ REACT_APP_OBSERVATORY_API_URL: https://observatory-api.mdn.allizom.net
+
# Sentry.
SENTRY_DSN_BUILD: ${{ secrets.SENTRY_DSN_BUILD }}
SENTRY_ENVIRONMENT: stage
diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml
index c03defa5a6d4..4f8c96e0af0e 100644
--- a/.github/workflows/test-build.yml
+++ b/.github/workflows/test-build.yml
@@ -156,6 +156,9 @@ jobs:
# Playground
REACT_APP_PLAYGROUND_BASE_HOST: play.test.mdn.allizom.net
+
+ # Observatory
+ REACT_APP_OBSERVATORY_API_URL: https://observatory-api.mdn.allizom.net
run: |
# Info about which CONTENT_* environment variables were set and to what.
diff --git a/build/spas.ts b/build/spas.ts
index 2d827eb3ccf7..2cfce722b3dc 100644
--- a/build/spas.ts
+++ b/build/spas.ts
@@ -12,6 +12,8 @@ import {
VALID_LOCALES,
MDN_PLUS_TITLE,
DEFAULT_LOCALE,
+ OBSERVATORY_TITLE_FULL,
+ OBSERVATORY_TITLE,
} from "../libs/constants/index.js";
import {
CONTENT_ROOT,
@@ -38,11 +40,17 @@ const FEATURED_ARTICLES = [
];
const LATEST_NEWS: (NewsItem | string)[] = [
+ "blog/mdn-http-observatory-launch/",
"blog/mdn-curriculum-launch/",
"blog/baseline-evolution-on-mdn/",
"blog/introducing-the-mdn-playground/",
];
+const PAGE_DESCRIPTIONS = Object.freeze({
+ observatory:
+ "Test your site’s HTTP headers, including CSP and HSTS, to find security problems and get actionable recommendations to make your website more secure. Test other websites to see how you compare.",
+});
+
const contributorSpotlightRoot = CONTRIBUTOR_SPOTLIGHT_ROOT;
async function buildContributorSpotlight(
@@ -145,6 +153,27 @@ export async function buildSPAs(options: {
const SPAs = [
{ prefix: "play", pageTitle: "Playground | MDN" },
+ {
+ prefix: "observatory",
+ pageTitle: `HTTP Header Security Test - ${OBSERVATORY_TITLE_FULL}`,
+ pageDescription: PAGE_DESCRIPTIONS.observatory,
+ },
+ {
+ prefix: "observatory/analyze",
+ pageTitle: `Scan results - ${OBSERVATORY_TITLE_FULL}`,
+ pageDescription: PAGE_DESCRIPTIONS.observatory,
+ noIndexing: true,
+ },
+ {
+ prefix: "observatory/docs/tests_and_scoring",
+ pageTitle: `Tests & Scoring - ${OBSERVATORY_TITLE_FULL}`,
+ pageDescription: PAGE_DESCRIPTIONS.observatory,
+ },
+ {
+ prefix: "observatory/docs/faq",
+ pageTitle: `FAQ - ${OBSERVATORY_TITLE_FULL}`,
+ pageDescription: PAGE_DESCRIPTIONS.observatory,
+ },
{ prefix: "search", pageTitle: "Search", onlyFollow: true },
{ prefix: "plus", pageTitle: MDN_PLUS_TITLE },
{
@@ -182,10 +211,17 @@ export async function buildSPAs(options: {
},
];
const locale = VALID_LOCALES.get(pathLocale) || pathLocale;
- for (const { prefix, pageTitle, noIndexing, onlyFollow } of SPAs) {
+ for (const {
+ prefix,
+ pageTitle,
+ pageDescription,
+ noIndexing,
+ onlyFollow,
+ } of SPAs) {
const url = `/${locale}/${prefix}`;
const context: HydrationData = {
pageTitle,
+ pageDescription,
locale,
noIndexing,
onlyFollow,
@@ -283,6 +319,11 @@ export async function buildSPAs(options: {
"plus/docs",
"MDN Plus"
);
+ await buildStaticPages(
+ fileURLToPath(new URL("../copy/observatory/", import.meta.url)),
+ "observatory/docs",
+ OBSERVATORY_TITLE
+ );
// Build all the home pages in all locales.
// Fetch merged content PRs for the latest contribution section.
diff --git a/client/public/assets/observatory/assessment.svg b/client/public/assets/observatory/assessment.svg
new file mode 100644
index 000000000000..5ef0f1390efc
--- /dev/null
+++ b/client/public/assets/observatory/assessment.svg
@@ -0,0 +1,4 @@
+
+
+
\ No newline at end of file
diff --git a/client/public/assets/observatory/fail-icon.svg b/client/public/assets/observatory/fail-icon.svg
new file mode 100644
index 000000000000..ace6fbcc3b9e
--- /dev/null
+++ b/client/public/assets/observatory/fail-icon.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/public/assets/observatory/landing-illustration.svg b/client/public/assets/observatory/landing-illustration.svg
new file mode 100644
index 000000000000..5d25bb039ab8
--- /dev/null
+++ b/client/public/assets/observatory/landing-illustration.svg
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/public/assets/observatory/lines.svg b/client/public/assets/observatory/lines.svg
new file mode 100644
index 000000000000..e0f766dd409f
--- /dev/null
+++ b/client/public/assets/observatory/lines.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/public/assets/observatory/mdn.svg b/client/public/assets/observatory/mdn.svg
new file mode 100644
index 000000000000..ffe515b7fe65
--- /dev/null
+++ b/client/public/assets/observatory/mdn.svg
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/client/public/assets/observatory/pass-icon.svg b/client/public/assets/observatory/pass-icon.svg
new file mode 100644
index 000000000000..b341bf1c2970
--- /dev/null
+++ b/client/public/assets/observatory/pass-icon.svg
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/client/public/assets/observatory/results-icon.svg b/client/public/assets/observatory/results-icon.svg
new file mode 100644
index 000000000000..343bcaf47d80
--- /dev/null
+++ b/client/public/assets/observatory/results-icon.svg
@@ -0,0 +1,4 @@
+
+
+
\ No newline at end of file
diff --git a/client/public/assets/observatory/scanning.svg b/client/public/assets/observatory/scanning.svg
new file mode 100644
index 000000000000..61131a145746
--- /dev/null
+++ b/client/public/assets/observatory/scanning.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/client/public/assets/observatory/security.svg b/client/public/assets/observatory/security.svg
new file mode 100644
index 000000000000..499702596a37
--- /dev/null
+++ b/client/public/assets/observatory/security.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/client/public/assets/observatory/stars.svg b/client/public/assets/observatory/stars.svg
new file mode 100644
index 000000000000..8a18c6ffa278
--- /dev/null
+++ b/client/public/assets/observatory/stars.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/client/public/assets/observatory/summary-icon.svg b/client/public/assets/observatory/summary-icon.svg
new file mode 100644
index 000000000000..4a0728efcf71
--- /dev/null
+++ b/client/public/assets/observatory/summary-icon.svg
@@ -0,0 +1,4 @@
+
+
+
\ No newline at end of file
diff --git a/client/public/assets/observatory/tooltip-arrow.svg b/client/public/assets/observatory/tooltip-arrow.svg
new file mode 100644
index 000000000000..5c1afd6ccdc7
--- /dev/null
+++ b/client/public/assets/observatory/tooltip-arrow.svg
@@ -0,0 +1,5 @@
+
+
+
\ No newline at end of file
diff --git a/client/src/app.tsx b/client/src/app.tsx
index 99a43ee4f148..a64b26a9e056 100644
--- a/client/src/app.tsx
+++ b/client/src/app.tsx
@@ -42,6 +42,7 @@ const Translations = React.lazy(() => import("./translations"));
const WritersHomepage = React.lazy(() => import("./writers-homepage"));
const Sitemap = React.lazy(() => import("./sitemap"));
const Playground = React.lazy(() => import("./playground"));
+const Observatory = React.lazy(() => import("./observatory"));
function Layout({ pageType, children }) {
const { pathname } = useLocation();
@@ -62,7 +63,7 @@ function Layout({ pageType, children }) {
} ${pageType}`}
>
- {pageType !== "document-page" && pageType !== "curriculum" && (
+ {!["document-page", "curriculum", "observatory"].includes(pageType) && (
@@ -87,6 +88,7 @@ function LoadingFallback({ message }: { message?: string }) {
}
function LazyStandardLayout(props: {
+ pageType?: string;
extraClasses?: string;
children: React.ReactNode;
}) {
@@ -101,14 +103,18 @@ function LazyStandardLayout(props: {
}
function StandardLayout({
+ pageType,
extraClasses,
children,
}: {
+ pageType?: string;
extraClasses?: string;
children: React.ReactNode;
}) {
return (
- {children}
+
+ {children}
+
);
}
function DocumentLayout({ children }) {
@@ -266,6 +272,14 @@ export function App(appProps: HydrationData) {
}
/>
+
+
+
+ }
+ />
+
+
\ No newline at end of file
diff --git a/client/src/env.ts b/client/src/env.ts
index cfeb2bfe4336..5364afdeee26 100644
--- a/client/src/env.ts
+++ b/client/src/env.ts
@@ -131,3 +131,7 @@ export function survey_rates(surveyKey: string): {
);
return { rateFrom, rateTill };
}
+
+export const OBSERVATORY_API_URL =
+ process.env.REACT_APP_OBSERVATORY_API_URL ||
+ "https://observatory-api.mdn.allizom.net";
diff --git a/client/src/homepage/static-page/index.tsx b/client/src/homepage/static-page/index.tsx
index 68970eaafce2..45459c8be912 100644
--- a/client/src/homepage/static-page/index.tsx
+++ b/client/src/homepage/static-page/index.tsx
@@ -1,11 +1,12 @@
import React, { ReactElement } from "react";
import useSWR from "swr";
-import { DEV_MODE } from "../../env";
+import { DEV_MODE, PLACEMENT_ENABLED } from "../../env";
import { SidebarContainer } from "../../document/organisms/sidebar";
import { TOC } from "../../document/organisms/toc";
import { Toc } from "../../../../libs/types/document";
import { PageNotFound } from "../../page-not-found";
import { Loading } from "../../ui/atoms/loading";
+import { SidePlacement } from "../../ui/organisms/placement";
interface StaticPageDoc {
id: string;
@@ -14,13 +15,15 @@ interface StaticPageDoc {
toc: Toc[];
}
-interface StaticPageProps {
+export interface StaticPageProps {
extraClasses?: string;
locale: string;
slug: string;
fallbackData?: any;
title?: string;
sidebarHeader?: ReactElement;
+ children?: React.ReactNode;
+ additionalToc?: Toc[];
}
function StaticPage({
@@ -30,6 +33,8 @@ function StaticPage({
fallbackData = undefined,
title = "MDN",
sidebarHeader = <>>,
+ children = <>>,
+ additionalToc = [],
}: StaticPageProps) {
const baseURL = `/${locale}/${slug}`;
const featureJSONUrl = `${baseURL}/index.json`;
@@ -60,17 +65,24 @@ function StaticPage({
return ;
}
- const toc = hyData.toc?.length && ;
-
return (
<>
-
- {sidebarHeader || null}
-
-
+
+
+ {sidebarHeader || null}
+
+
+
+
+ {hyData.toc && !!hyData.toc.length && (
+
+ )}
+
+
+ {PLACEMENT_ENABLED &&
}
+
+
{hyData.sections.map((section, index) => (
@@ -79,6 +91,7 @@ function StaticPage({
dangerouslySetInnerHTML={{ __html: section }}
>
))}
+ {children}
diff --git a/client/src/observatory/benchmark-chart/index.scss b/client/src/observatory/benchmark-chart/index.scss
new file mode 100644
index 000000000000..80d8a30fd5ea
--- /dev/null
+++ b/client/src/observatory/benchmark-chart/index.scss
@@ -0,0 +1,61 @@
+@use "../../ui/vars" as *;
+
+.observatory {
+ svg.chart {
+ background-color: var(--background-primary);
+ border-radius: var(--border-radius);
+
+ .tick text {
+ fill: var(--observatory-color-secondary);
+ font-family: var(--font-body);
+ font-size: 1rem;
+ font-weight: 300;
+ transform: scale(1);
+
+ &.x-labels {
+ text-anchor: middle;
+
+ &.current {
+ fill: var(--grade-border);
+ }
+ }
+
+ &.y-labels {
+ text-anchor: end;
+ }
+ }
+
+ .tick line {
+ color: var(--observatory-color-secondary);
+ opacity: 0.9;
+ stroke: var(--observatory-border);
+ stroke-dasharray: 5, 5;
+ stroke-width: 1px;
+ }
+
+ .bar {
+ fill: var(--grade-bg);
+ stroke: var(--grade-bg);
+ stroke-width: 1;
+
+ &.current-grade {
+ stroke: var(--grade-border);
+ }
+ }
+
+ .you-are-here {
+ polyline {
+ fill: var(--background-primary);
+ filter: drop-shadow(0 0 3px rgb(170 170 170));
+ z-index: 9;
+ }
+
+ text {
+ fill: var(--text-primary);
+ font-family: var(--font-body);
+ font-size: 0.85rem;
+ font-weight: 300;
+ }
+ }
+ }
+}
diff --git a/client/src/observatory/benchmark-chart/index.tsx b/client/src/observatory/benchmark-chart/index.tsx
new file mode 100644
index 000000000000..1279a8f5bfdc
--- /dev/null
+++ b/client/src/observatory/benchmark-chart/index.tsx
@@ -0,0 +1,253 @@
+import { GradeDistribution, ObservatoryResult } from "../types";
+import { formatMinus } from "../utils";
+import "./index.scss";
+
+export default function GradeSVG({
+ gradeDistribution,
+ result,
+}: {
+ gradeDistribution: GradeDistribution[];
+ result: ObservatoryResult;
+}) {
+ const width = 1200;
+ const height = 380;
+ const leftSpace = 100; // left edge to left edge of first bar
+ const rightSpace = 80; // right edge to tight edge of last bar
+ const bottomSpace = 60; // bottom edge to bottom edge of bars
+ const topSpace = 60; // top padding
+ const itemCount = gradeDistribution.length;
+ const barWidth = 60;
+
+ // The x-axis has the different grades from "A+" to "D-".
+ const xTickIncr =
+ (width - leftSpace - rightSpace - barWidth) / (itemCount - 1);
+ const xTickOffset = leftSpace + xTickIncr / 2;
+
+ // The y-axis has ticks according to the maximum value of all grades.
+ const yMarks = calculateTicks(gradeDistribution);
+ const yTickOffset = height - bottomSpace;
+ const yTickIncr = (height - bottomSpace - topSpace) / (yMarks.length - 1);
+ const yTickMax = Math.max(...yMarks);
+
+ return (
+ <>
+
+ Number of sites by grade
+
+
+ Grade
+ Sites
+
+
+
+ {gradeDistribution.map((item, index) => (
+
+
+ {formatMinus(item.grade)}
+ {item.grade === result.scan.grade ? " (Current grade)" : ""}
+
+ {item.count} sites
+
+ ))}
+
+
+
+ Number of sites by grade
+
+
+ {gradeDistribution.map((item, index) => (
+
+
+ {formatMinus(item.grade)}
+
+
+ ))}
+
+
+ {gradeDistribution.map((item, index) => {
+ // draw the individual grade bars
+ const barHeight =
+ (height - bottomSpace - topSpace) * (item.count / yTickMax);
+ return (
+
+ );
+ })}
+
+
+ {yMarks.map((item, index) => (
+
+
+
+ {/* format as kilo-sites, which works well for our current and future ranges below 10^6*/}
+ {item / 1000}k
+
+
+ ))}
+
+
+
+ {gradeDistribution.map((item, index) => {
+ // Draw the "This website is here" marker. Drawn explicitly last so it is above all other elements in the drawing.
+ if (item.grade === result.scan.grade) {
+ const barHeight =
+ (height - bottomSpace - topSpace) * (item.count / yTickMax);
+ return (
+
+
+
+ Current grade
+
+
+ );
+ } else {
+ return [];
+ }
+ })}
+
+
+ >
+ );
+}
+
+/**
+ * Calculate
+ * @param {GradeDistribution[]} gradeDistribution
+ * @returns {number[]}
+ */
+function calculateTicks(gradeDistribution: GradeDistribution[]): number[] {
+ const maxValue = Math.max(...gradeDistribution.map((item) => item.count));
+ const tickTargetCount = 7; // Target number of ticks between 5 and 10
+ const range = rangeForValue(maxValue, false); // Get a nice range
+ const tickInterval = rangeForValue(range / tickTargetCount, true); // Determine a nice tick interval
+ const niceMaxValue = Math.ceil(maxValue / tickInterval) * tickInterval; // Adjust max value to a nice number
+ const tickCount = Math.ceil(niceMaxValue / tickInterval) + 1; // Calculate the number of ticks
+
+ const ticks: number[] = [];
+ for (let i = 0; i < tickCount; i++) {
+ ticks.push(i * tickInterval);
+ }
+ return ticks;
+}
+
+/**
+ * This returns values to construct proper axis measurements in
+ * diagrams. The returned value is 1|2|5 * 10^x.
+ *
+ * If `round` is `true`, the returned value can be also rounded down,
+ * useful for calculating ticks on an axis.
+ *
+ * Examples:
+ *
+ * |range |rounded=false|rounded=true|
+ * |---------|-------------|------------|
+ * | 1 | 1 | 1 |
+ * | 2 | 2 | 2 |
+ * | 3 | 5 | 5 |
+ * | 4 | 5 | 5 |
+ * | 5 | 5 | 5 |
+ * | 6 | 10 | 5 |
+ * | 7 | 10 | 10 |
+ * | 8 | 10 | 10 |
+ * | 9 | 10 | 10 |
+ * | 10 | 10 | 10 |
+ * | 34 | 50 | 50 |
+ * | 450 | 500 | 500 |
+ * | 560 | 1000 | 500 |
+ * | 6780 | 10000 | 5000 |
+ * | 10 | 10 | 10 |
+ * | 100 | 100 | 100 |
+ * | 1000 | 1000 | 1000 |
+ * | 10000 | 10000 | 10000 |
+ *
+ * @param {number} range The input value
+ * @param {boolean} round If false, the returned value will always be greater than `range`, otherwise it can be rounded off
+ * @returns {number} a number according to `1|2|5 * 10^x`, where x is derived from `range` to be in the same order of magnitude
+ */
+
+function rangeForValue(range: number, round: boolean): number {
+ const exponent = Math.floor(Math.log10(range));
+ const fraction = range / Math.pow(10, exponent);
+
+ let niceFraction: number;
+ if (round) {
+ if (fraction < 1.5) {
+ niceFraction = 1;
+ } else if (fraction < 3) {
+ niceFraction = 2;
+ } else if (fraction < 7) {
+ niceFraction = 5;
+ } else {
+ niceFraction = 10;
+ }
+ } else {
+ if (fraction <= 1) {
+ niceFraction = 1;
+ } else if (fraction <= 2) {
+ niceFraction = 2;
+ } else if (fraction <= 5) {
+ niceFraction = 5;
+ } else {
+ niceFraction = 10;
+ }
+ }
+ return niceFraction * Math.pow(10, exponent);
+}
diff --git a/client/src/observatory/docs/index.scss b/client/src/observatory/docs/index.scss
new file mode 100644
index 000000000000..f5a94ea9a271
--- /dev/null
+++ b/client/src/observatory/docs/index.scss
@@ -0,0 +1,28 @@
+@use "../../ui/vars" as *;
+
+.observatory {
+ --category-color: var(--observatory-accent);
+
+ .article-actions-container {
+ display: flex;
+ }
+
+ .sidebar {
+ @media (min-width: $screen-md) {
+ padding-top: 3rem;
+ }
+ }
+
+ &,
+ .observatory-wrapper {
+ .document-toc-heading {
+ font-size: var(--type-base-font-size-rem);
+ margin-top: 1.5rem;
+
+ a,
+ a:hover {
+ text-decoration: none;
+ }
+ }
+ }
+}
diff --git a/client/src/observatory/docs/index.tsx b/client/src/observatory/docs/index.tsx
new file mode 100644
index 000000000000..76ff31e4af77
--- /dev/null
+++ b/client/src/observatory/docs/index.tsx
@@ -0,0 +1,217 @@
+import { useParams, useLocation } from "react-router-dom";
+import StaticPage, { StaticPageProps } from "../../homepage/static-page";
+import "./index.scss";
+import { useLocale } from "../../hooks";
+import { ObservatoryLayout } from "../layout";
+import {
+ OBSERVATORY_TITLE,
+ OBSERVATORY_TITLE_FULL,
+} from "../../../../libs/constants";
+import useSWR from "swr";
+import { OBSERVATORY_API_URL } from "../../env";
+import { Loading } from "../../ui/atoms/loading";
+
+export const ITEMS = [
+ {
+ slug: "observatory/docs/tests_and_scoring",
+ title: "Tests & Scoring",
+ },
+ {
+ slug: "observatory/docs/faq",
+ title: "FAQ",
+ },
+];
+
+export function ObservatoryDocsNav() {
+ return ;
+}
+
+function RelatedTopics({
+ items,
+}: {
+ items: { slug: string; title: string }[];
+}) {
+ const locale = useLocale();
+ const { pathname: locationPathname } = useLocation();
+
+ return (
+
+
+
+
+ {items.map(({ slug, title }) => {
+ const itemPathname = `/${locale}/${slug}`;
+
+ return (
+
+
+ {title}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+interface ObservatoryMatrixResultEntry {
+ name: string;
+ scoreModifier: number;
+ description: { __html: string };
+ recommendation: { __html: string };
+}
+
+interface ObservatoryMatrixEntry {
+ name: string;
+ title: string;
+ mdnLink: string;
+ results: ObservatoryMatrixResultEntry[];
+}
+
+function ObservatoryMatrix({
+ data,
+}: {
+ data?: ObservatoryMatrixEntry[] | null;
+}) {
+ return (
+ <>
+ {data &&
+ data?.map((entry) => {
+ return (
+
+ {entry.title}
+
+ See {entry.title} for guidance.
+
+
+
+
+
+ Test result
+ Description
+ Modifier
+
+
+
+ {entry.results.map((result) => {
+ return (
+
+ {result.name}
+
+ {result.scoreModifier}
+
+ );
+ })}
+
+
+
+
+ );
+ })}
+ >
+ );
+}
+
+function TestAndScoringPage({ ...props }: StaticPageProps) {
+ const { "*": slug } = useParams();
+
+ const { data, isLoading } = useSWR(
+ `${OBSERVATORY_API_URL}/api/v2/recommendation_matrix`,
+ async (url) => {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(await response.text());
+ }
+ let data = await response.json();
+ data.map((entry) =>
+ entry.results.map((result) => {
+ result.description = { __html: result.description };
+ result.recommendation = { __html: result.recommendation };
+ return result;
+ })
+ );
+ return data;
+ },
+ {
+ revalidateOnFocus: false,
+ revalidateIfStale: false,
+ revalidateOnReconnect: false,
+ }
+ );
+
+ let additionalToc = data?.map((entry) => ({
+ text: entry.title,
+ id: entry.name,
+ }));
+
+ return isLoading ? (
+
+ ) : (
+
+ {slug === "tests_and_scoring" && (
+ <>
+
+ >
+ )}{" "}
+
+ );
+}
+
+function GenericDoc({ ...props }: StaticPageProps) {
+ return ;
+}
+
+function ObservatoryDocs({ ...props }) {
+ const { pathname } = useLocation();
+ const locale = useLocale();
+ const { "*": slug } = useParams();
+
+ const sidebarHeader = ;
+
+ const fullSlug = `observatory/docs/${slug}`;
+
+ const staticPageProps = {
+ extraClasses: "plus-docs",
+ locale,
+ slug: fullSlug,
+ title: OBSERVATORY_TITLE_FULL,
+ sidebarHeader,
+ fallbackData: props.hyData ? props : undefined,
+ };
+
+ return (
+ i.slug === fullSlug)?.title ?? "Documentation",
+ uri: pathname,
+ },
+ ]}
+ withSidebar={true}
+ >
+ {slug === "tests_and_scoring" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default ObservatoryDocs;
diff --git a/client/src/observatory/index.scss b/client/src/observatory/index.scss
new file mode 100644
index 000000000000..58671727f9a3
--- /dev/null
+++ b/client/src/observatory/index.scss
@@ -0,0 +1,138 @@
+@use "../ui/vars" as *;
+
+.observatory {
+ --border-radius: 0.3rem;
+ --code-background-block: var(--observatory-bg-code);
+ --spacing: 2rem;
+ background-color: var(--observatory-bg);
+
+ h1,
+ h2 {
+ font-style: normal;
+ font-weight: 600;
+ letter-spacing: var(--heading-letter-spacing);
+ }
+
+ .obs-none {
+ color: var(--observatory-color-secondary);
+ }
+
+ .obs-score-value {
+ margin-right: 0.5rem;
+ }
+
+ .obs-pass-icon {
+ svg.pass {
+ path {
+ fill: var(--observatory-pass-icon-bg);
+ }
+
+ circle {
+ fill: var(--observatory-pass-icon-color);
+ }
+ }
+
+ svg.fail {
+ path {
+ fill: var(--observatory-fail-icon-bg);
+ }
+
+ circle {
+ fill: var(--observatory-fail-icon-color);
+ }
+ }
+ }
+
+ .accent {
+ color: var(--observatory-accent);
+ }
+
+ .observatory-wrapper {
+ display: grid;
+ grid-template-areas: "header header header" "main main main" ". sidebar .";
+ grid-template-columns: 1rem 1fr 1rem;
+ max-width: var(--max-width);
+ padding: 1.5rem 1rem;
+
+ section.header {
+ grid-area: header;
+ }
+
+ section.main {
+ grid-area: main;
+ }
+
+ a {
+ color: var(--observatory-color);
+ text-decoration: underline;
+ text-decoration-color: var(--observatory-color-secondary);
+
+ &:hover,
+ &:active {
+ text-decoration: none;
+ }
+ }
+
+ .feedback-link {
+ --feedback-link-icon: var(--observatory-color-secondary);
+ --text-link: var(--observatory-color-secondary);
+ color: var(--observatory-color-secondary);
+ display: block;
+ font-size: var(--type-tiny-font-size);
+ margin-top: 1.5rem;
+ }
+
+ .feedback-link.faq-link::before {
+ mask-image: url("../assets/icons/message-question.svg");
+ }
+
+ .error {
+ color: var(--form-invalid-color);
+ margin-top: 0.5rem;
+
+ &::before {
+ background-color: var(--form-invalid-color);
+ content: "";
+ display: inline-block;
+ height: 1.15rem;
+ margin-bottom: 0.25rem;
+ margin-right: 0.5rem;
+ mask-image: url("../assets/icons/alert-circle.svg");
+ mask-position: center;
+ mask-repeat: no-repeat;
+ vertical-align: middle;
+ width: 1.5em;
+ }
+
+ + form input {
+ outline-color: var(--observatory-border-accent);
+ }
+ }
+
+ @media (min-width: $screen-md) {
+ column-gap: 1rem;
+ grid-template-areas: "header sidebar" "main sidebar";
+ grid-template-columns: minmax(0, 1fr) 12rem;
+ padding-left: 1rem;
+ padding-right: 1rem;
+
+ section.header,
+ section.main {
+ padding: 0;
+ }
+ }
+
+ @media (min-width: $screen-xl) {
+ column-gap: 3rem;
+ grid-template-areas: "header sidebar" "main sidebar";
+ grid-template-columns: 2fr minmax(0, 200px);
+ }
+ }
+
+ .scroll-container {
+ margin-bottom: 1.5rem;
+ margin-top: 0.8rem;
+ overflow-x: auto;
+ overscroll-behavior-x: none;
+ }
+}
diff --git a/client/src/observatory/index.tsx b/client/src/observatory/index.tsx
new file mode 100644
index 000000000000..47e53faa91e2
--- /dev/null
+++ b/client/src/observatory/index.tsx
@@ -0,0 +1,21 @@
+import { Route, Routes } from "react-router-dom";
+
+import { PageNotFound } from "../page-not-found";
+
+import ObservatoryLanding from "./landing";
+import ObservatoryResults from "./results";
+import ObservatoryDocs from "./docs";
+import "./index.scss";
+
+export default function Observatory({ ...props }) {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/client/src/observatory/landing.scss b/client/src/observatory/landing.scss
new file mode 100644
index 000000000000..c14dcb255eb0
--- /dev/null
+++ b/client/src/observatory/landing.scss
@@ -0,0 +1,233 @@
+@use "../ui/vars" as *;
+
+.observatory {
+ .observatory-landing.observatory-landing-top {
+ background: var(--observatory-bg-secondary);
+
+ .observatory-wrapper {
+ grid-template-areas: "header .";
+
+ @media (max-width: #{$screen-xl - 1}) {
+ grid-template-areas: "header";
+ grid-template-columns: auto;
+ }
+ }
+ }
+
+ .observatory-landing {
+ background: var(--observatory-bg);
+
+ .observatory-wrapper {
+ .feedback-link,
+ .faq-link {
+ display: inline-block;
+ margin-right: 1rem;
+ margin-top: 2rem;
+ }
+ }
+
+ .place {
+ grid-area: sidebar;
+ }
+
+ .header {
+ display: grid;
+ gap: 0 6.25rem;
+ grid-template-areas: "form svg";
+ grid-template-columns: 2fr 1fr;
+
+ .scan-form {
+ grid-area: form;
+ }
+
+ .landing-illustration {
+ grid-area: svg;
+
+ svg {
+ height: auto;
+ max-width: 50vw;
+ width: 100%;
+ }
+ }
+
+ @media (max-width: #{$screen-md - 1}) {
+ grid-template:
+ "form" auto
+ "svg" auto;
+
+ .landing-illustration {
+ text-align: center;
+ }
+ }
+ }
+
+ h1 {
+ margin-bottom: unset;
+ }
+
+ p {
+ color: var(--observatory-color-secondary);
+ }
+
+ form {
+ .input-group {
+ display: flex;
+ height: 3rem;
+
+ :focus-visible {
+ outline: 1px solid var(--observatory-accent);
+ outline-offset: -1px;
+ outline-width: 1px;
+ }
+
+ ::placeholder {
+ color: var(--observatory-color-secondary);
+ opacity: 0.8;
+ }
+
+ input {
+ background-color: var(--observatory-bg);
+ border: 1px solid var(--observatory-border);
+ border-bottom-left-radius: var(--border-radius);
+ border-top-left-radius: var(--border-radius);
+ flex-grow: 1;
+ padding: 0 0.75rem;
+ width: 100%;
+
+ &::placeholder {
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ button {
+ background: var(--button-primary-default);
+ border-bottom-right-radius: var(--border-radius);
+ border-top-right-radius: var(--border-radius);
+ color: var(--background-primary);
+ cursor: pointer;
+ font: var(--type-emphasis-m);
+ font-size: 1rem;
+ padding: 0 2rem;
+
+ &:hover {
+ background: var(--button-primary-hover);
+ }
+
+ &:active {
+ background: var(--button-primary-active);
+ }
+ }
+ }
+
+ label {
+ align-items: center;
+ display: flex;
+ margin: 1.75rem 0;
+ }
+
+ input[type="checkbox"] {
+ height: 1.25rem;
+ margin: 0;
+ margin-right: 0.5rem;
+ width: 1.25rem;
+ }
+ }
+
+ .main {
+ background: var(--observatory-bg);
+ border-radius: var(--border-radius);
+ margin: 1rem 0;
+
+ h2 {
+ margin-top: unset;
+ }
+
+ .about {
+ display: flex;
+ flex-direction: column;
+
+ h2 {
+ margin-bottom: 2rem;
+ }
+
+ // Make a gradient from the accent color to the background color,
+ // replacing the light-mode-only colors in the original SVG data.
+ svg.lines defs#defs3 #gradient {
+ stop#stop1 {
+ stop-color: var(--observatory-accent);
+ }
+
+ stop#stop2 {
+ stop-color: color-mix(
+ in srgb,
+ var(--observatory-accent),
+ var(--observatory-bg)
+ );
+ }
+
+ stop#stop3 {
+ stop-color: var(--observatory-bg);
+ }
+ }
+
+ figure {
+ &.assessment,
+ &.scanning,
+ &.security,
+ &.mdn {
+ svg path {
+ fill: var(--observatory-accent);
+ }
+ }
+ }
+ }
+
+ @media (min-width: $screen-md) {
+ .assessment {
+ margin-left: 0;
+ }
+
+ .scanning {
+ margin-left: 3.125rem;
+ }
+
+ .security {
+ margin-left: 6.25rem;
+ }
+
+ .mdn {
+ margin-left: 9.75rem;
+ }
+ }
+ @media (max-width: #{$screen-md - 1}) {
+ figure ~ figure {
+ margin-top: 1.75rem;
+ }
+
+ .lines {
+ display: none;
+ }
+ }
+ @media (min-width: $screen-md) and (max-width: #{$screen-lg - 1}) {
+ .about-copy {
+ width: 80%;
+ }
+ }
+
+ figure {
+ align-items: start;
+ display: grid;
+ gap: 1.5rem;
+ grid-template-columns: 2rem 1fr;
+
+ figcaption {
+ p {
+ color: var(--observatory-color);
+ margin: 0;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/client/src/observatory/landing.tsx b/client/src/observatory/landing.tsx
new file mode 100644
index 000000000000..df04d2ea95d7
--- /dev/null
+++ b/client/src/observatory/landing.tsx
@@ -0,0 +1,208 @@
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { useGleanClick } from "../telemetry/glean-context";
+import { OBSERVATORY } from "../telemetry/constants";
+import Container from "../ui/atoms/container";
+import { SidePlacement } from "../ui/organisms/placement";
+import {
+ OBSERVATORY_TITLE,
+ OBSERVATORY_TITLE_FULL,
+} from "../../../libs/constants";
+
+import { ObservatoryAnalyzeRequest } from "./types";
+import { ObservatoryLayout } from "./layout";
+import { Progress } from "./progress";
+import { ERROR_MAP, FaqLink, FeedbackLink, useUpdateResult } from "./utils";
+
+import "./landing.scss";
+import { ReactComponent as LandingSVG } from "../../public/assets/observatory/landing-illustration.svg";
+import { ReactComponent as LinesSVG } from "../../public/assets/observatory/lines.svg";
+import { ReactComponent as AssessmentSVG } from "../../public/assets/observatory/assessment.svg";
+import { ReactComponent as ScanningSVG } from "../../public/assets/observatory/scanning.svg";
+import { ReactComponent as SecuritySVG } from "../../public/assets/observatory/security.svg";
+import { ReactComponent as MdnSVG } from "../../public/assets/observatory/mdn.svg";
+
+export default function ObservatoryLanding() {
+ document.title = `HTTP Header Security Test - ${OBSERVATORY_TITLE_FULL}`;
+
+ const [form, setForm] = useState({
+ host: "",
+ });
+ const [cleanHostname, setCleanHostname] = useState("");
+ const {
+ trigger,
+ isMutating,
+ data,
+ error: updateError,
+ } = useUpdateResult(cleanHostname);
+ const [error, setError] = useState();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ try {
+ // tolerate url-style host values and pick out the hostname part
+ const url = new URL(form.host);
+ setCleanHostname(url.hostname.trim() || form.host);
+ } catch {
+ setCleanHostname(form.host);
+ }
+ }, [form.host]);
+
+ useEffect(() => {
+ if (!isMutating && data) {
+ if (data.scan.error) {
+ setError(new Error(data.scan.error));
+ } else {
+ navigate(`./analyze?host=${encodeURIComponent(cleanHostname)}`);
+ }
+ }
+ }, [isMutating, data, navigate, cleanHostname]);
+
+ useEffect(() => {
+ setError(updateError);
+ }, [updateError]);
+
+ const submit: React.FormEventHandler = async (e) => {
+ e.preventDefault();
+ setError(undefined);
+ if (form.host.trim().length === 0) {
+ setError(new Error("Please enter a valid hostname"));
+ } else {
+ trigger();
+ }
+ };
+
+ const gleanClick = useGleanClick();
+
+ useEffect(() => {
+ if (error && !isMutating) {
+ gleanClick(
+ `${OBSERVATORY}: error -> ${ERROR_MAP[error.name] || error.message}`
+ );
+ }
+ }, [error, isMutating, gleanClick]);
+
+ return (
+
+
+
+
+
+
+ {OBSERVATORY_TITLE}
+
+
+ Launched in 2016, the HTTP Observatory enhances web security by
+ analyzing compliance with best security practices. It has
+ provided insights to over 6.9 million websites through 47
+ million scans. If you still need to access the previous version
+ of HTTP Observatory,{" "}
+ click here .
+ Please note the old Observatory is now deprecated and will soon
+ be sunsetted.
+
+ {isMutating ? (
+
+ ) : (
+
+ )}
+ {error && !isMutating && (
+
+ Error: {ERROR_MAP[error.name] || error.message}
+
+ )}
+
+
+
+
+
+
+
+
+
+ About the HTTP Observatory
+
+
+
+
+
+ Developed by Mozilla, the HTTP Observatory performs an
+ in-depth assessment of a site’s HTTP headers and other key
+ security configurations.
+
+
+
+
+
+
+
+
+ Its automated scanning process provides developers and
+ website administrators with detailed, actionable feedback,
+ focusing on identifying and addressing potential security
+ vulnerabilities.
+
+
+
+
+
+
+
+
+ The tool is instrumental in helping developers and website
+ administrators strengthen their sites against common
+ security threats in a constantly advancing digital
+ environment.
+
+
+
+
+
+
+
+
+ The HTTP Observatory provides effective security insights,
+ guided by Mozilla's expertise and commitment to a safer
+ and more secure internet and based on well-established
+ trends and guidelines.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/observatory/layout.tsx b/client/src/observatory/layout.tsx
new file mode 100644
index 000000000000..e9923ab375ec
--- /dev/null
+++ b/client/src/observatory/layout.tsx
@@ -0,0 +1,35 @@
+import { ReactNode } from "react";
+import { DocParent } from "../../../libs/types/document";
+import { ArticleActionsContainer } from "../ui/organisms/article-actions-container";
+import { TopNavigation } from "../ui/organisms/top-navigation";
+import { DEFAULT_LOCALE } from "../../../libs/constants";
+import { OBSERVATORY_TITLE } from "../../../libs/constants";
+
+export function ObservatoryLayout({
+ parents = [],
+ children,
+ withSidebar = false,
+}: {
+ parents?: DocParent[];
+ children: ReactNode;
+ withSidebar?: boolean;
+}) {
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/client/src/observatory/progress/index.scss b/client/src/observatory/progress/index.scss
new file mode 100644
index 000000000000..91365032ee71
--- /dev/null
+++ b/client/src/observatory/progress/index.scss
@@ -0,0 +1,34 @@
+.progress-bar {
+ background-color: var(--observatory-bg);
+ height: 4px;
+ overflow: hidden;
+ width: 100%;
+}
+
+.progress-bar-value {
+ animation: indeterminateAnimation 2.5s infinite linear;
+ background-color: var(--observatory-accent);
+ height: 100%;
+ transform-origin: 0% 50%;
+ width: 100%;
+}
+
+@keyframes indeterminateAnimation {
+ 0% {
+ transform: translateX(0) scaleX(0);
+ }
+
+ 40% {
+ transform: translateX(0) scaleX(0.4);
+ }
+
+ 100% {
+ transform: translateX(100%) scaleX(0.5);
+ }
+}
+
+@media (prefers-reduced-motion) {
+ .progress-bar-value {
+ animation-duration: 60s;
+ }
+}
diff --git a/client/src/observatory/progress/index.tsx b/client/src/observatory/progress/index.tsx
new file mode 100644
index 000000000000..a0d01dcd6fb5
--- /dev/null
+++ b/client/src/observatory/progress/index.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+
+import "./index.scss";
+
+export function useTimeout(callback: React.EffectCallback, delay: number) {
+ React.useEffect(() => {
+ const timeout = setTimeout(callback, delay);
+ return () => clearTimeout(timeout);
+ }, [delay, callback]);
+}
+
+export function Progress({ message = "Scanning…" }: { message?: string }) {
+ return (
+
+ );
+}
diff --git a/client/src/observatory/results.scss b/client/src/observatory/results.scss
new file mode 100644
index 000000000000..cc1bacbfcf52
--- /dev/null
+++ b/client/src/observatory/results.scss
@@ -0,0 +1,540 @@
+@use "../ui/vars" as *;
+
+.observatory-results {
+ .sidebar {
+ grid-area: sidebar;
+ padding-top: 0;
+
+ .place {
+ display: flex;
+ }
+ }
+
+ h2::before {
+ background-color: var(--observatory-color);
+ content: "";
+ display: inline-block;
+ mask-position: left;
+ mask-repeat: no-repeat;
+ mask-size: contain;
+ }
+
+ h2.summary::before {
+ height: 1.4rem;
+ mask-image: url("../../public/assets/observatory/summary-icon.svg");
+ width: 2rem;
+ }
+
+ h2.result::before {
+ height: 1.2rem;
+ mask-image: url("../../public/assets/observatory/results-icon.svg");
+ width: 2rem;
+ }
+
+ h2.summary .host {
+ font-weight: initial;
+ }
+
+ .heading-and-actions {
+ align-items: start;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1.5rem;
+ justify-content: space-between;
+ margin-bottom: 3rem;
+
+ h1 {
+ align-self: flex-start;
+ }
+
+ .actions {
+ align-self: center;
+ }
+ }
+
+ h1 {
+ letter-spacing: var(--heading-letter-spacing);
+ margin-bottom: unset;
+ }
+
+ h2 {
+ font-size: 1.375rem;
+ font-weight: 600;
+ letter-spacing: var(--heading-letter-spacing);
+ margin-bottom: 1rem;
+ margin-top: 1.5rem;
+ }
+
+ p {
+ font-weight: 400;
+ letter-spacing: -0.03rem;
+ }
+
+ code {
+ font-weight: 600;
+ }
+
+ .arrow-down {
+ color: var(--observatory-arrow-down-color);
+ }
+
+ .arrow-up {
+ color: var(--observatory-arrow-up-color);
+ }
+
+ .grade-trend {
+ grid-area: grade;
+ justify-self: start;
+
+ .trend {
+ color: var(--observatory-color-secondary);
+ font-weight: 400;
+ margin-top: 1rem;
+ }
+ }
+
+ .scan-results {
+ .footnote {
+ font-size: var(--type-smaller-font-size);
+ margin-top: 1rem;
+ }
+
+ table {
+ background: var(--observatory-table-bg);
+ border: none;
+ min-width: calc($screen-lg - 2rem - 12rem);
+
+ th {
+ background: var(--observatory-table-header-bg);
+ color: var(--text-secondary);
+ font-weight: 500;
+ vertical-align: top;
+ }
+
+ tr:nth-child(odd) {
+ background-color: var(--observatory-table-bg-alternate);
+ }
+
+ td {
+ overflow-wrap: anywhere;
+ padding: 0.5rem 1.5rem;
+ vertical-align: top;
+
+ &.cookie-name:first-child {
+ max-width: 15rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &.score > span {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ }
+ }
+
+ a {
+ color: var(--observatory-color);
+ text-decoration: underline;
+ text-decoration-color: var(--observatory-color-secondary);
+
+ &:hover,
+ &:active {
+ text-decoration: none;
+ }
+ }
+
+ span.not-counted {
+ color: var(--observatory-color-secondary);
+
+ a {
+ text-decoration: none;
+ }
+ }
+
+ // Some column width hints on the different result table.
+ &.tests {
+ th,
+ td {
+ &:first-of-type {
+ width: 25%;
+ }
+ }
+
+ td.score {
+ white-space: nowrap;
+ }
+ }
+
+ &.csp {
+ th,
+ td {
+ &:first-of-type {
+ width: 45%;
+ }
+ }
+ }
+
+ &.headers {
+ th,
+ td {
+ &:first-of-type {
+ width: 30%;
+ }
+ }
+ }
+
+ &.cookies {
+ th,
+ td {
+ &:nth-of-type(n + 3) {
+ text-align: center;
+ }
+ }
+ }
+
+ th,
+ td {
+ border: none;
+ padding: 1.5rem;
+
+ :first-child {
+ margin-top: 0;
+ }
+
+ :last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .icon {
+ height: 1.3rem;
+ width: 1.3rem;
+ }
+
+ @media (max-width: #{$screen-lg - 0.02}) {
+ // responsive table
+ min-width: 0;
+
+ thead {
+ display: none;
+ }
+
+ tbody {
+ display: block;
+ }
+
+ tr {
+ display: grid;
+ grid-template-columns: max-content auto;
+ }
+
+ tr:nth-of-type(odd) {
+ background: var(--observatory-table-header-bg);
+ }
+
+ td {
+ column-gap: 2em;
+ display: grid;
+ grid-auto-flow: column;
+ grid-column: span 2;
+ grid-template-columns: subgrid;
+ }
+
+ td:before {
+ content: attr(data-header);
+ display: inline;
+ font-weight: 600;
+ }
+
+ &.tests,
+ &.csp,
+ &.headers,
+ &.cookies {
+ td:first-of-type {
+ width: auto;
+ }
+ }
+
+ td:not(:last-of-type) {
+ padding-bottom: 0;
+ }
+
+ td:nth-of-type(n + 2) {
+ padding-top: 0.75rem;
+ }
+
+ &.cookies {
+ td:nth-of-type(n + 3) {
+ text-align: left;
+ }
+ }
+
+ td.score {
+ display: grid;
+
+ > span {
+ display: block;
+ }
+
+ .obs-pass-icon {
+ display: inline-block;
+ height: 1.5em;
+ vertical-align: top;
+ }
+ }
+ }
+ }
+
+ input[type="radio"]:not(:checked) ~ .tab-content {
+ display: none;
+ }
+
+ ol.tabs-list {
+ column-gap: 3rem;
+ display: grid;
+ grid-template-areas:
+ "tab-0 tab-1 tab-2 tab-3 tab-4 tab-5 ."
+ "hr hr hr hr hr hr hr "
+ "mod mod mod mod mod mod mod";
+ grid-template-columns: repeat(6, max-content) 1fr;
+ margin: 0;
+ overflow-x: auto;
+ overscroll-behavior-x: none;
+ padding: 0;
+
+ @media (max-width: #{$screen-lg - 0.02}) {
+ column-gap: 1.75rem;
+ grid-template-columns: repeat(6, max-content) auto;
+ }
+
+ &::before {
+ border: none;
+ border-top: 1px solid var(--observatory-border);
+ content: "";
+ grid-area: hr;
+ margin: 0;
+ width: 100%;
+ }
+
+ li.tabs-list-item {
+ display: contents;
+
+ > input:checked + label {
+ border-bottom: 2px solid var(--observatory-accent);
+ color: var(--text-primary);
+ }
+
+ > input:not(:checked) + label:hover {
+ border-bottom: 2px solid var(--observatory-accent-light);
+ color: var(--text-primary);
+ }
+
+ > input:checked:focus-visible + label {
+ outline-color: var(--accent-primary);
+ outline-offset: 1px;
+ outline-style: auto;
+ }
+
+ > input:not(:checked) + label {
+ color: var(--text-secondary);
+ opacity: 0.775;
+ }
+
+ > label {
+ cursor: pointer;
+ height: 2.2rem;
+ width: max-content;
+ }
+
+ tabs-0 {
+ > label,
+ > input {
+ grid-area: tab-0;
+ }
+ }
+
+ tabs-1 {
+ > label,
+ > input {
+ grid-area: tab-1;
+ }
+ }
+
+ tabs-2 {
+ > label,
+ > input {
+ grid-area: tab-2;
+ }
+ }
+
+ tabs-3 {
+ > label,
+ > input {
+ grid-area: tab-3;
+ }
+ }
+
+ tabs-4 {
+ > label,
+ > input {
+ grid-area: tab-4;
+ }
+ }
+
+ tabs-5 {
+ > label,
+ > input {
+ grid-area: tab-5;
+ }
+ }
+
+ > section.tab-content {
+ grid-area: mod;
+ left: 0;
+ margin: 0;
+ position: sticky;
+
+ @media (max-width: #{$screen-lg - 0.02}) {
+ width: calc(
+ 100vw - 12rem - 3rem
+ ); // 12rem: placement width; 3rem: padding
+ }
+ @media (max-width: #{$screen-md - 0.02}) {
+ width: calc(100vw - 2rem); // 2rem: padding
+ }
+ }
+ }
+ }
+ }
+
+ section.scan-rescan {
+ background-color: var(--background-primary);
+ border-radius: var(--border-radius);
+ justify-content: space-between;
+ margin-bottom: 3rem;
+ max-width: var(--max-width);
+ padding: var(--spacing);
+ }
+
+ section.scan-result {
+ background-color: var(--background-primary);
+ border-radius: var(--border-radius);
+ column-gap: var(--spacing);
+ display: grid;
+ grid-template-areas: "grade data actions";
+ grid-template-columns: auto 1fr auto;
+ justify-content: space-between;
+ margin-bottom: 3rem;
+ max-width: var(--max-width);
+ padding: var(--spacing);
+
+ .progress {
+ border-radius: 50%;
+ display: block;
+ height: 0.8rem;
+ width: 0.8rem;
+ }
+
+ a {
+ color: var(--observatory-color);
+ text-decoration: none;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ text-decoration-color: var(--observatory-color-secondary);
+ }
+
+ .scan-another {
+ font-size: var(--type-smaller-font-size);
+ font-weight: 400;
+ margin-top: 1.2rem;
+
+ a {
+ color: var(--observatory-color-secondary);
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .label {
+ font-weight: 600;
+ }
+
+ .actions {
+ grid-area: actions;
+
+ .button-wrap {
+ width: 9rem;
+ }
+ }
+
+ .data {
+ grid-area: data;
+ }
+
+ @media (max-width: #{$screen-xl - 1}) {
+ align-items: flex-start;
+ grid-template-areas: "grade data actions";
+ grid-template-columns: auto 1fr auto;
+ justify-content: space-between;
+ row-gap: calc(var(--spacing) / 2);
+
+ .data {
+ align-self: start;
+ }
+ }
+
+ @media (max-width: #{$screen-lg - 1}) {
+ grid-template-areas: "grade data" ". actions";
+ grid-template-columns: min-content 1fr;
+ }
+
+ @media (max-width: #{$screen-md - 1}) {
+ padding: calc(var(--spacing) / 2);
+ }
+ }
+
+ .grade {
+ background: var(--grade-bg);
+ border: 1px solid var(--grade-border);
+ border-radius: 0.2em;
+ color: var(--grade-border);
+ display: inline-block;
+ font-size: 1.7rem;
+ font-weight: 600;
+ height: 5rem;
+ line-height: 5rem;
+ text-align: center;
+ width: 5rem;
+ }
+
+ .grade-a {
+ --grade-bg: var(--observatory-grade-a-bg);
+ --grade-border: var(--observatory-grade-a-border);
+ }
+
+ .grade-b {
+ --grade-bg: var(--observatory-grade-b-bg);
+ --grade-border: var(--observatory-grade-b-border);
+ }
+
+ .grade-c {
+ --grade-bg: var(--observatory-grade-c-bg);
+ --grade-border: var(--observatory-grade-c-border);
+ }
+
+ .grade-d {
+ --grade-bg: var(--observatory-grade-d-bg);
+ --grade-border: var(--observatory-grade-d-border);
+ }
+
+ .grade-f {
+ --grade-bg: var(--observatory-grade-f-bg);
+ --grade-border: var(--observatory-grade-f-border);
+ }
+}
diff --git a/client/src/observatory/results.tsx b/client/src/observatory/results.tsx
new file mode 100644
index 000000000000..7667b6244281
--- /dev/null
+++ b/client/src/observatory/results.tsx
@@ -0,0 +1,233 @@
+import { useEffect, useMemo, useState } from "react";
+import { Navigate, useLocation } from "react-router";
+import { useSearchParams } from "react-router-dom";
+
+import { useGleanClick } from "../telemetry/glean-context";
+import { OBSERVATORY } from "../telemetry/constants";
+import Container from "../ui/atoms/container";
+
+import ObservatoryCSP from "./results/csp";
+import {
+ ERROR_MAP,
+ FeedbackLink,
+ useResult,
+ useUpdateResult as useRescanTrigger,
+} from "./utils";
+import { ObservatoryLayout } from "./layout";
+import { Progress } from "./progress";
+import { ObservatoryDocsNav } from "./docs";
+import { ObservatoryCookies } from "./results/cookies";
+import { ObservatoryHeaders } from "./results/headers";
+import { ObservatoryHistory } from "./results/history";
+import { ObservatoryRating } from "./results/rating";
+import { ObservatoryTests } from "./results/tests";
+import ObservatoryBenchmark from "./results/benchmark";
+import "./results.scss";
+import {
+ OBSERVATORY_TITLE,
+ OBSERVATORY_TITLE_FULL,
+} from "../../../libs/constants";
+import { SidebarContainer } from "../document/organisms/sidebar";
+
+export default function ObservatoryResults() {
+ const { pathname, search } = useLocation();
+ const [searchParams] = useSearchParams();
+ const host = searchParams.get("host");
+
+ const { data: result, isLoading, error } = useResult(host!);
+
+ // Used for rescanning the current host
+ const {
+ trigger,
+ isMutating,
+ error: updateError,
+ } = useRescanTrigger(host || "");
+
+ const gleanClick = useGleanClick();
+
+ document.title = `Scan results for ${host} | ${OBSERVATORY_TITLE_FULL}`;
+
+ const combinedError = error || updateError;
+
+ useEffect(() => {
+ if (combinedError && !isMutating) {
+ gleanClick(
+ `${OBSERVATORY}: error -> ${ERROR_MAP[combinedError.name] || combinedError.message}`
+ );
+ }
+ }, [combinedError, isMutating, gleanClick]);
+
+ const hasData = host && result && !isLoading && !isMutating;
+ return host ? (
+
+
+
+
+
+
+ {OBSERVATORY_TITLE} Report{" "}
+
+
+
+ {hasData && !combinedError ? (
+
+ ) : isLoading ? (
+
+ ) : isMutating ? (
+
+ ) : (
+
+
+ Error:{" "}
+ {ERROR_MAP[combinedError.name] || combinedError.message}
+
+ Observatory Home
+
+ )}
+
+
+
+ { || null}
+
+
+ {hasData && !combinedError && (
+
+ )}
+
+
+
+ ) : (
+
+ );
+}
+
+function ObservatoryScanResults({ result, host }) {
+ const tabs = useMemo(() => {
+ return [
+ {
+ label: "Scoring",
+ key: "scoring",
+ element: ,
+ },
+ {
+ label: "CSP analysis",
+ key: "csp",
+ element: ,
+ },
+ {
+ label: "Raw server headers",
+ key: "headers",
+ element: ,
+ },
+ {
+ label: "Cookies",
+ key: "cookies",
+ element: ,
+ },
+ {
+ label: "Scan history",
+ key: "history",
+ element: ,
+ },
+ {
+ label: "Benchmark comparison",
+ key: "benchmark",
+ element: ,
+ },
+ ];
+ }, [result]);
+ const defaultTabKey = tabs[0].key!;
+ const initialTabKey = window.location.hash.replace("#", "") || defaultTabKey;
+ const initialTab = tabs.findIndex((tab) => tab.key === initialTabKey);
+ const [selectedTab, setSelectedTab] = useState(
+ initialTab === -1 ? 0 : initialTab
+ );
+ useEffect(() => {
+ const handleHashChange = () => {
+ const tabIndex = tabs.findIndex(
+ (tab) => tab.key === window.location.hash.replace("#", "")
+ );
+ setSelectedTab(tabIndex === -1 ? 0 : tabIndex);
+ };
+
+ window.addEventListener("hashchange", handleHashChange);
+ return () => {
+ window.removeEventListener("hashchange", handleHashChange);
+ };
+ });
+
+ const gleanClick = useGleanClick();
+
+ useEffect(() => {
+ const hash = tabs[selectedTab]?.key || defaultTabKey;
+ window.history.replaceState(
+ "",
+ "",
+ window.location.pathname +
+ window.location.search +
+ (hash !== defaultTabKey ? "#" + hash : "")
+ );
+ }, [tabs, selectedTab, defaultTabKey]);
+
+ return (
+
+ );
+}
diff --git a/client/src/observatory/results/benchmark.tsx b/client/src/observatory/results/benchmark.tsx
new file mode 100644
index 000000000000..9ae192ef0d30
--- /dev/null
+++ b/client/src/observatory/results/benchmark.tsx
@@ -0,0 +1,71 @@
+import useSWRImmutable from "swr/immutable";
+
+import { Loading } from "../../ui/atoms/loading";
+import NoteCard from "../../ui/molecules/notecards";
+
+import { GradeDistribution, ObservatoryResult } from "../types";
+import { OBSERVATORY_API_URL } from "../../env";
+import GradeSVG from "../benchmark-chart";
+import { handleJsonResponse } from "../utils";
+
+export function useGradeDistribution(grade: string | null | undefined) {
+ return useSWRImmutable("gradeDistribution", async () => {
+ const url = new URL(OBSERVATORY_API_URL + "/api/v2/grade_distribution");
+ const res = await fetch(url);
+ return await handleJsonResponse(res);
+ });
+}
+
+export async function handleGradeDistributionResponse(
+ res: Response
+): Promise {
+ if (!res.ok) {
+ let message = `${res.status}: ${res.statusText}`;
+ try {
+ const data = await res.json();
+ if (data.error) {
+ message = data.message;
+ }
+ } finally {
+ throw Error(message);
+ }
+ }
+ return await res.json();
+}
+
+export default function ObservatoryBenchmark({
+ result,
+}: {
+ result: ObservatoryResult;
+}) {
+ const {
+ data: gradeDistribution,
+ isLoading,
+ error,
+ } = useGradeDistribution(result.scan.grade);
+ const hasData = !!gradeDistribution;
+
+ return hasData ? (
+ <>
+ Performance trends from the past year
+ {
+
+ }
+
+ Refer to this graph to assess the website's current status. By following
+ the recommendations provided and rescanning, you can expect an
+ improvement in the website's grade.
+
+ >
+ ) : isLoading ? (
+
+ ) : (
+
+ Error
+ {error ? error.message : "An error occurred."}
+
+ );
+}
diff --git a/client/src/observatory/results/cookies.tsx b/client/src/observatory/results/cookies.tsx
new file mode 100644
index 000000000000..c72c647b44c3
--- /dev/null
+++ b/client/src/observatory/results/cookies.tsx
@@ -0,0 +1,117 @@
+import { ObservatoryResult } from "../types";
+import { formatDateTime, PassIcon } from "../utils";
+
+export function ObservatoryCookies({ result }: { result: ObservatoryResult }) {
+ const cookies = result.tests["cookies"]?.data;
+ return cookies && Object.keys(cookies).length !== 0 ? (
+
+
+
+ Name
+
+
+ Expires
+
+
+
+
+ Path
+
+
+
+
+ Secure
+
+
+
+
+ HttpOnly
+
+
+
+
+ SameSite
+
+
+
+
+ Prefix
+
+
+
+
+
+ {Object.entries(cookies).map(([key, value]) => (
+
+
+ {key}
+
+
+ {value.expires
+ ? formatDateTime(new Date(value.expires))
+ : "Session"}
+
+
+ {value.path}
+
+
+
+
+
+
+
+
+ {value.samesite ? {capitalize(value.samesite)}
: "-"}
+
+
+
+
+
+ ))}
+
+
+ ) : (
+
+
+
+ No cookies detected
+
+
+
+ );
+}
+
+function capitalize(input: string) {
+ return input
+ .split("-")
+ .map((p) => (p ? p[0].toUpperCase() + p.substring(1) : ""))
+ .join("-");
+}
+
+function CookiePrefix({ name }: { name: string }) {
+ if (name.startsWith("__Host-")) {
+ return Host
;
+ } else if (name.startsWith("__Secure-")) {
+ return Secure
;
+ } else {
+ return <>->;
+ }
+}
diff --git a/client/src/observatory/results/csp.tsx b/client/src/observatory/results/csp.tsx
new file mode 100644
index 000000000000..93f61265420f
--- /dev/null
+++ b/client/src/observatory/results/csp.tsx
@@ -0,0 +1,107 @@
+import { ObservatoryResult } from "../types";
+import { PassIcon } from "../utils";
+
+const policyTests = [
+ "unsafeInline",
+ "unsafeEval",
+ "unsafeObjects",
+ "unsafeInlineStyle",
+ "insecureSchemeActive",
+ "insecureSchemePassive",
+ "antiClickjacking",
+ "defaultNone",
+ "insecureBaseUri",
+ "insecureFormAction",
+ "strictDynamic",
+];
+
+export default function ObservatoryCSP({
+ result,
+}: {
+ result: ObservatoryResult;
+}) {
+ const policy = result.tests["content-security-policy"]?.policy;
+
+ // Awkward, but so it has been on python-observatory:
+ // Negate some of the `pass` flags because sometimes
+ // a `pass` on the policy is bad, and sometimes not.
+ const negatedPolicies = [
+ "insecureBaseUri",
+ "insecureFormAction",
+ "insecureSchemeActive",
+ "insecureSchemePassive",
+ "unsafeEval",
+ "unsafeInline",
+ "unsafeInlineStyle",
+ "unsafeObjects",
+ ];
+
+ return (
+
+ {policy ? (
+ <>
+
+
+ Test
+ Result
+ Info
+
+
+
+ {policyTests.map((pt) => {
+ return policy[pt] ? (
+
+
+
+
+
+
+
+ ) : (
+ []
+ );
+ })}
+
+ >
+ ) : (
+
+
+
+
+ {result.tests["content-security-policy"]?.result ===
+ "csp-not-implemented-but-reporting-enabled" ? (
+ <>
+ Content-Security-Policy-Report-Only
header
+ detected. Implement an enforced policy; see{" "}
+
+ MDN's Content Security Policy (CSP) documentation
+
+ .
+ >
+ ) : (
+ "No CSP headers detected"
+ )}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/client/src/observatory/results/headers.tsx b/client/src/observatory/results/headers.tsx
new file mode 100644
index 000000000000..2858693ab761
--- /dev/null
+++ b/client/src/observatory/results/headers.tsx
@@ -0,0 +1,63 @@
+import useSWRImmutable from "swr/immutable";
+
+import { ObservatoryResult } from "../types";
+
+export function ObservatoryHeaders({ result }: { result: ObservatoryResult }) {
+ return result.scan.response_headers ? (
+
+
+
+ Header
+ Value
+
+
+
+ {Object.entries(result.scan.response_headers).map(([header, value]) => (
+
+
+
+
+ {value}
+
+ ))}
+
+
+ ) : null;
+}
+
+function HeaderLink({ header }: { header: string }) {
+ // try a HEAD fetch for /en-US/docs/Web/HTTP/Headers//metadata.json
+ // if successful, link to /en-US/docs/Web/HTTP/Headers/
+ const { data } = useHeaderLink(header);
+ const hasData = !!data;
+ const displayHeaderName = upperCaseHeaderName(header);
+ return hasData ? (
+
+ {displayHeaderName}
+
+ ) : (
+ <>{displayHeaderName}>
+ );
+}
+
+function useHeaderLink(header: string) {
+ const prettyHeaderName = upperCaseHeaderName(header);
+ return useSWRImmutable(`headerLink-${header}`, async (key) => {
+ const url = `/en-US/docs/Web/HTTP/Headers/${encodeURIComponent(prettyHeaderName)}/metadata.json`;
+ try {
+ const res = await fetch(url, { method: "HEAD" });
+ return res.ok
+ ? `/en-US/docs/Web/HTTP/Headers/${encodeURIComponent(prettyHeaderName)}`
+ : null;
+ } catch (e) {
+ return null;
+ }
+ });
+}
+
+function upperCaseHeaderName(header: string) {
+ return header
+ .split("-")
+ .map((p) => (p ? p[0].toUpperCase() + p.substring(1) : ""))
+ .join("-");
+}
diff --git a/client/src/observatory/results/history.tsx b/client/src/observatory/results/history.tsx
new file mode 100644
index 000000000000..15bb9b973cd5
--- /dev/null
+++ b/client/src/observatory/results/history.tsx
@@ -0,0 +1,28 @@
+import { ObservatoryResult } from "../types";
+import { formatDateTime, formatMinus } from "../utils";
+
+export function ObservatoryHistory({ result }: { result: ObservatoryResult }) {
+ return result.history.length ? (
+ <>
+ Changes in score over time
+
+
+
+ Date
+ Score
+ Grade
+
+
+
+ {[...result.history].reverse().map(({ scanned_at, score, grade }) => (
+
+ {formatDateTime(new Date(scanned_at))}
+ {score}
+ {formatMinus(grade)}
+
+ ))}
+
+
+ >
+ ) : null;
+}
diff --git a/client/src/observatory/results/human-duration.tsx b/client/src/observatory/results/human-duration.tsx
new file mode 100644
index 000000000000..5eb5dacdadae
--- /dev/null
+++ b/client/src/observatory/results/human-duration.tsx
@@ -0,0 +1,51 @@
+import { useEffect, useState } from "react";
+
+import { formatDateTime } from "../utils";
+
+export function HumanDuration({ date }: { date: Date }) {
+ const [text, setText] = useState(() => displayString(date));
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setText(displayString(date));
+ }, 1000);
+
+ return () => clearInterval(interval);
+ });
+
+ return (
+
+ {text}
+
+ );
+}
+
+function displayString(date: Date) {
+ const currentTime = new Date().getTime();
+ const targetTime = date.getTime();
+ const diffSecs = Math.round((currentTime - targetTime) / 1000);
+
+ if (diffSecs < 0) {
+ return formatDateTime(date);
+ }
+
+ if (diffSecs < 60) {
+ return `Just now`;
+ }
+ if (diffSecs < 60 * 60) {
+ const minutes = Math.floor(diffSecs / 60);
+ return minutes === 1 ? `1 minute ago` : `${minutes} minutes ago`;
+ }
+ if (diffSecs < 60 * 60 * 24) {
+ const hours = Math.floor(diffSecs / 3600);
+ return hours === 1 ? `1 hour ago` : `${hours} hours ago`;
+ }
+ // up to 30 days as days
+ if (diffSecs < 60 * 60 * 24 * 30) {
+ const days = Math.floor(diffSecs / 86400);
+ return days === 1 ? `1 day ago` : `${days} days ago`;
+ }
+
+ // after a week, return the formatted date
+ return formatDateTime(date);
+}
diff --git a/client/src/observatory/results/rating.tsx b/client/src/observatory/results/rating.tsx
new file mode 100644
index 000000000000..bbcb1ac6ba63
--- /dev/null
+++ b/client/src/observatory/results/rating.tsx
@@ -0,0 +1,171 @@
+import { useMemo } from "react";
+
+import { useIsServer } from "../../hooks";
+import { useGleanClick } from "../../telemetry/glean-context";
+import InternalLink from "../../ui/atoms/internal-link";
+import { OBSERVATORY } from "../../telemetry/constants";
+import { ReactComponent as StarsSVG } from "../../../public/assets/observatory/stars.svg";
+
+import { ObservatoryResult, SCORING_TABLE } from "../types";
+import { formatMinus, hostAsRedirectChain } from "../utils";
+import { Tooltip } from "../tooltip";
+import { RescanButton } from "./rescan-button";
+import { HumanDuration } from "./human-duration";
+
+export function ObservatoryRating({
+ result,
+ host,
+ rescanTrigger,
+}: {
+ result: ObservatoryResult;
+ host: string;
+ rescanTrigger: () => void;
+}) {
+ const gleanClick = useGleanClick();
+ const isServer = useIsServer();
+
+ const arrowState = useMemo(() => {
+ const [oldScore, oldGrade] = result.history.length
+ ? [result.history.at(-2)?.score, result.history.at(-2)?.grade]
+ : [undefined, undefined];
+ const newScore = result.scan.score;
+ const newGrade = result.scan.grade;
+ if (
+ typeof newScore === "number" &&
+ typeof oldScore === "number" &&
+ newGrade !== oldGrade &&
+ newScore !== oldScore
+ ) {
+ return oldScore < newScore ? "up" : "down";
+ } else {
+ return "none";
+ }
+ }, [result.history, result.scan.grade, result.scan.score]);
+
+ return (
+ <>
+
+ Scan summary:{" "}
+ {hostAsRedirectChain(host, result)}
+
+
+
+
+
+
+ {formatMinus(result.scan.grade)}
+
+
+
+
+
+ Grade
+ Score
+
+
+
+ {SCORING_TABLE.map((st) => {
+ return (
+
+ {formatMinus(st.grade)}
+
+ {st.scoreText}{" "}
+ {result.scan.grade === st.grade && st.stars && (
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+ Score
+
+ : <>{result.scan.score} / 100>
+
+
+
+ Tests Passed
+
+ : {result.scan.tests_passed} /
+ {result.scan.tests_quantity}
+
+
+ {!isServer && (
+
+ )}
+
+ gleanClick(`${OBSERVATORY}: scan-another`)}
+ >
+ Scan another website
+
+
+
+
+ >
+ );
+}
+
+type ARROW_STATE = "up" | "down" | "none";
+
+function Trend({ arrowState }: { arrowState: ARROW_STATE }) {
+ switch (arrowState) {
+ case "up":
+ return (
+
+
+ ↗︎
+ {" "}
+ since last scan
+
+ );
+ case "down":
+ return (
+
+
+ ↘︎
+ {" "}
+ since last scan
+
+ );
+ default:
+ return [];
+ }
+}
diff --git a/client/src/observatory/results/rescan-button.tsx b/client/src/observatory/results/rescan-button.tsx
new file mode 100644
index 000000000000..217bfd585751
--- /dev/null
+++ b/client/src/observatory/results/rescan-button.tsx
@@ -0,0 +1,57 @@
+import { useEffect, useState } from "react";
+
+import { useGleanClick } from "../../telemetry/glean-context";
+import { OBSERVATORY } from "../../telemetry/constants";
+import { Button } from "../../ui/atoms/button";
+
+export function RescanButton({
+ from,
+ duration,
+ onClickHandler,
+}: {
+ from: Date;
+ duration: number;
+ onClickHandler: () => void;
+}) {
+ function calculateRemainingTime() {
+ const endTime = from.getTime() + duration * 1000;
+ return Math.max(0, endTime - new Date().getTime());
+ }
+ const [remainingTime, setRemainingTime] = useState(() =>
+ calculateRemainingTime()
+ );
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setRemainingTime(calculateRemainingTime());
+ }, 1000);
+
+ return () => clearInterval(interval);
+ });
+
+ const gleanClick = useGleanClick();
+
+ function rescan() {
+ gleanClick(`${OBSERVATORY}: rescan`);
+ onClickHandler();
+ }
+
+ const isExpired = remainingTime <= 0;
+ const remainingSecs = Math.floor(remainingTime / 1000) + 1;
+ const progressPercent = (remainingSecs * 100) / 60;
+ return !isExpired ? (
+
+
+ Wait {remainingSecs}s to rescan
+
+ ) : (
+ Rescan
+ );
+}
diff --git a/client/src/observatory/results/tests.tsx b/client/src/observatory/results/tests.tsx
new file mode 100644
index 000000000000..604f98684282
--- /dev/null
+++ b/client/src/observatory/results/tests.tsx
@@ -0,0 +1,110 @@
+import { useMemo } from "react";
+import { ObservatoryResult, TEST_NAMES_IN_ORDER } from "../types";
+import { formatMinus, Link, PassIcon } from "../utils";
+
+export function ObservatoryTests({ result }: { result: ObservatoryResult }) {
+ const showFootnote = useMemo(() => {
+ return (
+ (result.scan.score || 0) <= 90 &&
+ Object.entries(result.tests).find(([_n, t]) => t.score_modifier > 0)
+ );
+ }, [result]);
+
+ return Object.keys(result.tests).length !== 0 ? (
+ <>
+
+
+
+ Test
+ Score
+ Reason
+ Recommendation
+
+
+
+ {TEST_NAMES_IN_ORDER.map((name) => {
+ const test = result.tests[name];
+ return (
+ test && (
+
+
+ {test.title}
+
+ {test.pass === null ? (
+ -
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+ None`,
+ }}
+ />
+
+ )
+ );
+ })}
+
+
+ {showFootnote && (
+
+ * Normally awards bonus points, however, in this case they
+ are not included in the overall score (
+
+ find out why
+
+ ).
+
+ )}
+ >
+ ) : null;
+}
+
+function ScoreModifier({
+ overallScore,
+ scoreModifier,
+}: {
+ overallScore: number;
+ scoreModifier: number;
+}) {
+ const [bonusEligible, formattedScoreModifier] = useMemo(() => {
+ return [
+ overallScore >= 90,
+ formatMinus(`${scoreModifier > 0 ? `+${scoreModifier}` : scoreModifier}`),
+ ];
+ }, [overallScore, scoreModifier]);
+ return (
+ 0 ? "not-counted" : ""}`}
+ >
+ {!bonusEligible && scoreModifier > 0 ? (
+ <>
+ 0
+
+ *
+
+ >
+ ) : (
+ <>{formattedScoreModifier}>
+ )}
+
+ );
+}
diff --git a/client/src/observatory/tooltip/index.scss b/client/src/observatory/tooltip/index.scss
new file mode 100644
index 000000000000..2c0dcb2b0531
--- /dev/null
+++ b/client/src/observatory/tooltip/index.scss
@@ -0,0 +1,78 @@
+@use "../../ui/vars" as *;
+
+.observatory {
+ .tooltip-popup table {
+ border: 0;
+ border-collapse: collapse;
+ white-space: nowrap;
+ width: 10rem;
+
+ tr {
+ color: var(--observatory-inverse-color-secondary);
+ font-size: 0.875rem;
+
+ &.current {
+ color: var(--observatory-inverse-color);
+ }
+ }
+
+ th,
+ td {
+ background-color: unset;
+ border: 0;
+ font-weight: var(--font-body);
+ text-align: left;
+ width: 50%;
+ }
+
+ th {
+ font-size: 1rem;
+ padding: 0 0 0.75rem;
+ }
+
+ td {
+ padding: 0;
+
+ svg {
+ vertical-align: -0.3rem;
+ }
+ }
+ }
+
+ .info-tooltip {
+ position: relative;
+
+ .arrow {
+ fill: var(--button-primary-default);
+ margin-left: -20px;
+ margin-top: -2rem;
+ padding: 0;
+ position: absolute;
+ }
+
+ .tooltip-popup {
+ --tooltip-offset: 0;
+ background-color: var(--button-primary-default);
+ border-radius: var(--border-radius);
+ color: var(--observatory-inverse-color-secondary);
+ left: 50%;
+ margin-top: 2rem;
+ max-width: min(100vw, 20rem);
+ padding: 1.5rem;
+ position: absolute;
+ text-align: center;
+ top: 100%;
+ transform: translateX(var(--tooltip-offset));
+ visibility: hidden;
+ width: max-content;
+ z-index: 1;
+ }
+
+ &:hover,
+ &:focus {
+ .tooltip-popup {
+ visibility: visible;
+ }
+ }
+ }
+}
diff --git a/client/src/observatory/tooltip/index.tsx b/client/src/observatory/tooltip/index.tsx
new file mode 100644
index 000000000000..6a4e035f8926
--- /dev/null
+++ b/client/src/observatory/tooltip/index.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useRef, useState } from "react";
+import "./index.scss";
+import { ReactComponent as ArrowSVG } from "../../../public/assets/observatory/tooltip-arrow.svg";
+
+export function Tooltip({
+ children,
+ extraClasses,
+}: {
+ children: React.ReactNode;
+ extraClasses?: string;
+}) {
+ let ref = useRef(null);
+ let [style, setStyle] = useState>({});
+ useEffect(() => {
+ const onResize = () => {
+ const parentRect = ref.current?.parentElement?.getBoundingClientRect();
+ const parentWH = (parentRect?.width || 0) / 2;
+ const x = (parentRect?.x || 0) + parentWH;
+ const rect = ref.current?.getBoundingClientRect();
+ const wH = (rect?.width || 0) / 2;
+ const iW = window.innerWidth;
+ const offset =
+ -1 *
+ (x <= iW / 2 // if the center of the parent is on the left half of the window
+ ? x < wH // if the center of the parent is smaller than half of the tooltip
+ ? x
+ : wH
+ : // the center of the parent is on the right half of the window
+ x > iW - wH // if the inner width of the window is less than half the tooltip
+ ? 2 * wH - (iW - x)
+ : wH);
+ const tooltipOffset = `${offset.toFixed(2)}px`;
+ setStyle(Object.fromEntries([["--tooltip-offset", tooltipOffset]]));
+ };
+ onResize();
+ window.addEventListener("resize", onResize);
+
+ return () => {
+ window.removeEventListener("resize", onResize);
+ };
+ }, [ref]);
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
diff --git a/client/src/observatory/types.ts b/client/src/observatory/types.ts
new file mode 100644
index 000000000000..a262b1831305
--- /dev/null
+++ b/client/src/observatory/types.ts
@@ -0,0 +1,124 @@
+export interface ObservatoryAnalyzeRequest {
+ host: string;
+}
+
+export type ObservatoryScanState =
+ | "ABORTED"
+ | "FAILED"
+ | "FINISHED"
+ | "PENDING"
+ | "STARTING"
+ | "RUNNING";
+
+export const SCORING_TABLE = [
+ { grade: "A+", scoreText: "100+", score: 100, stars: true },
+ { grade: "A", scoreText: "90", score: 90, stars: true },
+ { grade: "A-", scoreText: "85", score: 85, stars: true },
+ { grade: "B+", scoreText: "80", score: 80 },
+ { grade: "B", scoreText: "70", score: 70 },
+ { grade: "B-", scoreText: "65", score: 65 },
+ { grade: "C+", scoreText: "60", score: 60 },
+ { grade: "C", scoreText: "50", score: 50 },
+ { grade: "C-", scoreText: "45", score: 45 },
+ { grade: "D+", scoreText: "40", score: 40 },
+ { grade: "D", scoreText: "30", score: 30 },
+ { grade: "D-", scoreText: "25", score: 25 },
+ { grade: "F", scoreText: "0", score: 0 },
+];
+
+// Maintain consistent test order.
+export const TEST_NAMES_IN_ORDER = [
+ "content-security-policy",
+ "cookies",
+ "cross-origin-resource-sharing",
+ "redirection",
+ "referrer-policy",
+ "strict-transport-security",
+ "subresource-integrity",
+ "x-content-type-options",
+ "x-frame-options",
+ "cross-origin-resource-policy",
+];
+
+export interface ObservatoryResult {
+ scan: ObservatoryScanResult;
+ tests: ObservatoryTestResult;
+ history: ObservatoryHistoryResult[];
+}
+
+export interface GradeDistribution {
+ grade: string;
+ count: number;
+}
+
+export interface ObservatoryScanResult {
+ algorithm_version: number;
+ scanned_at: string;
+ error?: string | null;
+ grade?: string | null;
+ id: number;
+ response_headers?: Record;
+ score?: number;
+ status_code?: number;
+ tests_failed: number;
+ tests_passed: number;
+ tests_quantity: number;
+}
+
+export type ObservatoryTestResult = Record;
+
+export interface ObservatoryIndividualTest {
+ data: null | ObservatoryCookiesData;
+ expectation: string;
+ name: string;
+ title: string;
+ link: string;
+ pass: boolean;
+ result: string;
+ score_description: string;
+ recommendation: string;
+ score_modifier: number;
+ policy?: ObservatoryCSPPolicy;
+ route?: string[];
+}
+
+export interface ObservatoryHistoryResult {
+ scanned_at: string;
+ grade: string;
+ id: number;
+ score: number;
+}
+
+export type ObservatoryCookiesData = Record<
+ string,
+ ObservatoryIndividualCookie
+>;
+
+export interface ObservatoryIndividualCookie {
+ domain: string;
+ expires: number;
+ httponly: boolean;
+ path: string;
+ samesite: string;
+ secure: boolean;
+}
+
+export interface ObservatoryPolicyItem {
+ pass: boolean | null;
+ description: string;
+ info: string;
+}
+
+export interface ObservatoryCSPPolicy {
+ antiClickjacking: ObservatoryPolicyItem;
+ defaultNone: ObservatoryPolicyItem;
+ insecureBaseUri: ObservatoryPolicyItem;
+ insecureFormAction: ObservatoryPolicyItem;
+ insecureSchemeActive: ObservatoryPolicyItem;
+ insecureSchemePassive: ObservatoryPolicyItem;
+ strictDynamic: ObservatoryPolicyItem;
+ unsafeEval: ObservatoryPolicyItem;
+ unsafeInline: ObservatoryPolicyItem;
+ unsafeInlineStyle: ObservatoryPolicyItem;
+ unsafeObjects: ObservatoryPolicyItem;
+}
diff --git a/client/src/observatory/utils.tsx b/client/src/observatory/utils.tsx
new file mode 100644
index 000000000000..f16050375a5c
--- /dev/null
+++ b/client/src/observatory/utils.tsx
@@ -0,0 +1,139 @@
+import useSWRMutation from "swr/mutation";
+import useSWRImmutable from "swr/immutable";
+
+import { OBSERVATORY_API_URL } from "../env";
+
+import { ObservatoryResult } from "./types";
+import { ReactComponent as PassSVG } from "../../public/assets/observatory/pass-icon.svg";
+import { ReactComponent as FailSVG } from "../../public/assets/observatory/fail-icon.svg";
+
+export function Link({ href, children }: { href: string; children: any }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function PassIcon({ pass }: { pass: boolean | null }) {
+ if (pass === null) {
+ return <>->;
+ }
+ return (
+
+ {pass ? : }
+ {pass ? "Passed" : "Failed"}
+
+ );
+}
+
+export function FeedbackLink() {
+ return (
+ // eslint-disable-next-line react/jsx-no-target-blank
+
+ Report Feedback
+
+ );
+}
+
+export function FaqLink() {
+ return (
+ // eslint-disable-next-line react/jsx-no-target-blank
+
+ Read our FAQ
+
+ );
+}
+
+export const ERROR_MAP = {
+ TypeError: "Observatory is currently down.", // `fetch()` errors catch-all
+};
+
+export function formatMinus(term: string | null | undefined) {
+ if (!term) {
+ return null;
+ }
+ // replace dash with unicode minus symbol
+ // −
+ // MINUS SIGN
+ // Unicode: U+2212, UTF-8: E2 88 92
+ return `${term}`.replaceAll(/-/g, "−");
+}
+
+export function useUpdateResult(host: string) {
+ return useSWRMutation(
+ host,
+ async (key: string) => {
+ const url = new URL(OBSERVATORY_API_URL + "/api/v2/analyze");
+ url.searchParams.set("host", key);
+ const res = await fetch(url, {
+ method: "POST",
+ });
+ return await handleJsonResponse(res);
+ },
+ { populateCache: true, throwOnError: false }
+ );
+}
+
+export function useResult(host?: string) {
+ return useSWRImmutable(host, async (key) => {
+ const url = new URL(OBSERVATORY_API_URL + "/api/v2/analyze");
+ url.searchParams.set("host", key);
+ const res = await fetch(url);
+ return await handleJsonResponse(res);
+ });
+}
+
+export async function handleJsonResponse(res: Response): Promise {
+ if (!res.ok && res.status !== 429) {
+ let message = `${res.status}: ${res.statusText}`;
+ try {
+ const data = await res.json();
+ if (data.error) {
+ message = data.message;
+ }
+ } finally {
+ throw Error(message);
+ }
+ }
+ return await res.json();
+}
+
+export function formatDateTime(date: Date): string {
+ return date.toLocaleString([], {
+ dateStyle: "medium",
+ timeStyle: "medium",
+ });
+}
+
+export function hostAsRedirectChain(host, result: ObservatoryResult) {
+ const chain = result.tests.redirection?.route;
+ if (!chain || chain.length < 1) {
+ return host;
+ }
+ try {
+ const firstUrl = new URL(chain[0]);
+ const lastUrl = new URL(chain[chain.length - 1]);
+ if (firstUrl.hostname === lastUrl.hostname) {
+ return host;
+ }
+ return `${firstUrl.hostname} → ${lastUrl.hostname}`;
+ } catch (e) {
+ return host;
+ }
+}
diff --git a/client/src/placement-context.tsx b/client/src/placement-context.tsx
index f7b4d969b2dd..847b86e6e330 100644
--- a/client/src/placement-context.tsx
+++ b/client/src/placement-context.tsx
@@ -23,7 +23,8 @@ export interface PlacementContextData
const PLACEMENT_MAP: Record = {
side: {
typ: "side",
- pattern: /\/[^/]+\/(play|docs\/|blog\/|curriculum\/[^$]|search$)/i,
+ pattern:
+ /\/[^/]+\/(play|docs\/|blog\/|observatory\/?|curriculum\/[^$]|search$)/i,
},
top: {
typ: "top-banner",
diff --git a/client/src/plus/ai-help/banners.tsx b/client/src/plus/ai-help/banners.tsx
index 9453ad223826..3044fb2b000e 100644
--- a/client/src/plus/ai-help/banners.tsx
+++ b/client/src/plus/ai-help/banners.tsx
@@ -28,7 +28,6 @@ export function AiHelpBanner({
? "Now with chat history, enhanced context, and optimized prompts."
: "Upgrade to MDN Plus 5 or MDN Supporter 10 to unlock the potential of GPT-4-powered AI Help."}
- This is a beta feature.
{!isSubscriber && (
)}
diff --git a/client/src/telemetry/constants.ts b/client/src/telemetry/constants.ts
index 4351ff6244d1..672ce00c9323 100644
--- a/client/src/telemetry/constants.ts
+++ b/client/src/telemetry/constants.ts
@@ -24,6 +24,7 @@ export const BANNER_AI_HELP_CLICK = "banner_ai_help_click";
export const PLAYGROUND = "play_action";
export const AI_EXPLAIN = "ai_explain";
export const SETTINGS = "settings";
+export const OBSERVATORY = "observatory";
export const A11Y_MENU = "a11y_menu";
diff --git a/client/src/ui/base/_themes.scss b/client/src/ui/base/_themes.scss
index f2cdd8fa1b8f..48a121e593de 100644
--- a/client/src/ui/base/_themes.scss
+++ b/client/src/ui/base/_themes.scss
@@ -188,6 +188,37 @@
--ai-help-icon: #{$mdn-color-light-theme-green-50};
--ai-help-accent-background-color: #{$mdn-color-light-theme-green-50}10;
+ --observatory-bg: rgba(242, 242, 245, 1);
+ --observatory-bg-code: #e1e1e1;
+ --observatory-bg-secondary: rgba(255, 255, 255, 1);
+ --observatory-color: #000;
+ --observatory-color-secondary: rgba(105, 105, 105, 1);
+ --observatory-inverse-color: rgba(255, 255, 255, 1);
+ --observatory-inverse-color-secondary: rgba(179, 179, 179, 1);
+ --observatory-accent: #5a23d7;
+ --observatory-accent-light: #5a23d7aa;
+ --observatory-border: rgba(228, 228, 246, 1);
+ --observatory-border-accent: rgba(90, 35, 215, 1);
+ --observatory-pass-icon-bg: rgba(229, 250, 230, 1);
+ --observatory-pass-icon-color: rgba(0, 121, 54, 1);
+ --observatory-fail-icon-bg: rgba(250, 229, 229, 1);
+ --observatory-fail-icon-color: rgba(211, 0, 56, 1);
+ --observatory-table-bg: rgba(255, 255, 255, 1);
+ --observatory-table-bg-alternate: #f9f9fb;
+ --observatory-table-header-bg: rgba(249, 249, 251, 1);
+ --observatory-grade-a-bg: #d2fadd;
+ --observatory-grade-a-border: #017a37;
+ --observatory-grade-b-bg: #e8fad2;
+ --observatory-grade-b-border: #547a01;
+ --observatory-grade-c-bg: #faf8d2;
+ --observatory-grade-c-border: #7a7001;
+ --observatory-grade-d-bg: #fae8d2;
+ --observatory-grade-d-border: #a65001;
+ --observatory-grade-f-bg: #fad2d2;
+ --observatory-grade-f-border: #a00;
+ --observatory-arrow-down-color: rgba(158, 0, 39, 1);
+ --observatory-arrow-up-color: rgba(0, 121, 54, 1);
+
--form-limit-color: #{$mdn-color-neutral-60};
--form-limit-color-emphasis: #{$mdn-color-neutral-70};
--form-invalid-color: #{$mdn-color-light-theme-red-60};
@@ -465,6 +496,37 @@
--ai-help-icon: #{$mdn-color-dark-theme-green-30};
--ai-help-accent-background-color: #{$mdn-color-light-theme-green-50}30;
+ --observatory-bg: rgba(52, 52, 52, 1);
+ --observatory-bg-code: #4d4d4d;
+ --observatory-bg-secondary: rgba(0, 0, 0, 1);
+ --observatory-color: #fff;
+ --observatory-color-secondary: rgba(249, 249, 251, 1);
+ --observatory-inverse-color: rgb(27, 27, 27);
+ --observatory-inverse-color-secondary: rgba(105, 105, 105, 1);
+ --observatory-accent: #a388ff;
+ --observatory-accent-light: #a388ffaa;
+ --observatory-border: rgba(105, 105, 105, 1);
+ --observatory-border-accent: rgba(163, 136, 255, 1);
+ --observatory-pass-icon-bg: rgba(38, 92, 61, 1);
+ --observatory-pass-icon-color: rgba(138, 255, 163, 1);
+ --observatory-fail-icon-bg: rgba(92, 38, 38, 1);
+ --observatory-fail-icon-color: rgba(255, 121, 155, 1);
+ --observatory-table-bg: rgb(27, 27, 27);
+ --observatory-table-bg-alternate: #212121;
+ --observatory-table-header-bg: rgb(27, 27, 27);
+ --observatory-grade-a-bg: #265c3d;
+ --observatory-grade-a-border: #89fca1;
+ --observatory-grade-b-bg: #52662a;
+ --observatory-grade-b-border: #d5fc88;
+ --observatory-grade-c-bg: #66602a;
+ --observatory-grade-c-border: #fcf988;
+ --observatory-grade-d-bg: #5c3d26;
+ --observatory-grade-d-border: #ff6a00;
+ --observatory-grade-f-bg: #5c2626;
+ --observatory-grade-f-border: #fc8888;
+ --observatory-arrow-down-color: rgba(255, 112, 127, 1);
+ --observatory-arrow-up-color: rgba(0, 255, 106, 1);
+
--form-limit-color: #{$mdn-color-neutral-40};
--form-limit-color-emphasis: #{$mdn-color-neutral-30};
--form-invalid-color: #{$mdn-color-light-theme-red-30};
diff --git a/client/src/ui/molecules/main-menu/index.tsx b/client/src/ui/molecules/main-menu/index.tsx
index 0776c689995a..a61dc3e1e28d 100644
--- a/client/src/ui/molecules/main-menu/index.tsx
+++ b/client/src/ui/molecules/main-menu/index.tsx
@@ -6,13 +6,12 @@ import { PlusMenu } from "../plus-menu";
import "./index.scss";
import { PLUS_IS_ENABLED } from "../../../env";
-import { useLocale } from "../../../hooks";
import { useGleanClick } from "../../../telemetry/glean-context";
import { MENU } from "../../../telemetry/constants";
import { useLocation } from "react-router";
+import { ToolsMenu } from "../tools-menu";
export default function MainMenu({ isOpenOnMobile }) {
- const locale = useLocale();
const previousActiveElement = useRef(null);
const mainMenuRef = useRef(null);
const [visibleSubMenuId, setVisibleSubMenuId] = useState(null);
@@ -82,14 +81,12 @@ export default function MainMenu({ isOpenOnMobile }) {
toggleMenu={toggleMenu}
/>
)}
-
- CurriculumNew
-
+ Curriculum
Blog
- Play
-
- AI Help Beta
-
+
);
diff --git a/client/src/ui/molecules/menu/index.tsx b/client/src/ui/molecules/menu/index.tsx
index f89f90ec1440..8dc272cc1fa4 100644
--- a/client/src/ui/molecules/menu/index.tsx
+++ b/client/src/ui/molecules/menu/index.tsx
@@ -25,8 +25,9 @@ export const Menu = ({
isActive =
isActive ??
- (typeof menu.to === "string" &&
- pathname.startsWith(menu.to.split("#", 2)[0]));
+ (typeof menu.to === "string"
+ ? pathname.startsWith(menu.to.split("#", 2)[0])
+ : menu.items.some((item) => item.url && pathname.startsWith(item.url)));
const hasAnyDot = menu.items.some((item) => item.dot);
return (
diff --git a/client/src/ui/molecules/plus-menu/index.tsx b/client/src/ui/molecules/plus-menu/index.tsx
index 9455dc5011cb..5aa5138688c7 100644
--- a/client/src/ui/molecules/plus-menu/index.tsx
+++ b/client/src/ui/molecules/plus-menu/index.tsx
@@ -39,7 +39,7 @@ export const PlusMenu = ({ visibleSubMenuId, toggleMenu }) => {
description: "Get real-time assistance and support",
hasIcon: true,
iconClasses: "submenu-icon",
- label: "AI Help (beta)",
+ label: "AI Help",
url: aiHelpUrl,
},
...(!isServer && isAuthenticated
diff --git a/client/src/ui/molecules/submenu/index.tsx b/client/src/ui/molecules/submenu/index.tsx
index 9f2313a1f71a..1931e98db100 100644
--- a/client/src/ui/molecules/submenu/index.tsx
+++ b/client/src/ui/molecules/submenu/index.tsx
@@ -1,3 +1,4 @@
+import { ReactNode } from "react";
import { MENU } from "../../../telemetry/constants";
import { useGleanClick } from "../../../telemetry/glean-context";
import "./index.scss";
@@ -17,7 +18,7 @@ export type SubmenuItem = {
export type MenuEntry = {
id: string;
items: SubmenuItem[];
- label: string;
+ label: string | ReactNode;
to?: string;
};
diff --git a/client/src/ui/molecules/tools-menu/index.scss b/client/src/ui/molecules/tools-menu/index.scss
new file mode 100644
index 000000000000..a5082ad29ca4
--- /dev/null
+++ b/client/src/ui/molecules/tools-menu/index.scss
@@ -0,0 +1,11 @@
+@use "../../vars" as *;
+
+@media screen and (min-width: $screen-lg) {
+ #tools-button {
+ display: flex;
+
+ &::after {
+ display: none;
+ }
+ }
+}
diff --git a/client/src/ui/molecules/tools-menu/index.tsx b/client/src/ui/molecules/tools-menu/index.tsx
new file mode 100644
index 000000000000..67bf16d06219
--- /dev/null
+++ b/client/src/ui/molecules/tools-menu/index.tsx
@@ -0,0 +1,45 @@
+import { OBSERVATORY_TITLE } from "../../../../../libs/constants";
+import { useLocale } from "../../../hooks";
+import { Menu } from "../menu";
+
+import "./index.scss";
+
+export const ToolsMenu = ({ visibleSubMenuId, toggleMenu }) => {
+ const locale = useLocale();
+
+ const menu = {
+ id: "tools",
+ label: (
+ <>
+ Tools New
+ >
+ ),
+ items: [
+ {
+ description: "Write, test and share your code",
+ hasIcon: true,
+ iconClasses: "submenu-icon",
+ label: "Playground",
+ url: `/${locale}/play`,
+ },
+ {
+ description: "Scan a website for free",
+ hasIcon: true,
+ iconClasses: "submenu-icon",
+ label: OBSERVATORY_TITLE,
+ url: `/en-US/observatory`,
+ dot: "New",
+ },
+ {
+ description: "Get real-time assistance and support",
+ hasIcon: true,
+ iconClasses: "submenu-icon",
+ label: "AI Help",
+ url: `/en-US/plus/ai-help`,
+ },
+ ],
+ };
+ const isOpen = visibleSubMenuId === menu.id;
+
+ return ;
+};
diff --git a/client/src/ui/organisms/top-navigation-main/index.scss b/client/src/ui/organisms/top-navigation-main/index.scss
index 437c286f1417..dd19294bbd8e 100644
--- a/client/src/ui/organisms/top-navigation-main/index.scss
+++ b/client/src/ui/organisms/top-navigation-main/index.scss
@@ -81,7 +81,6 @@
}
&.menu-toggle {
- min-height: 53px;
padding: 0.5rem;
}
diff --git a/copy/observatory/faq.md b/copy/observatory/faq.md
new file mode 100644
index 000000000000..4aaf53149b97
--- /dev/null
+++ b/copy/observatory/faq.md
@@ -0,0 +1,138 @@
+---
+title: FAQ
+---
+
+# Frequently asked questions
+
+## Should I implement all recommendations?
+
+Yes, you should do it if possible. There is no way to programmatically determine
+the risk level of any given site. However, while your site may not be high-risk,
+it is still worth learning about the defensive security standards highlighted by
+Observatory, and implementing them wherever you can.
+
+## If I get an A+ grade, does that mean my site is secure?
+
+We'd love to say that any site that gets an A+ Observatory grade is perfectly
+secure, but there are a lot of security considerations that we can't test.
+Observatory tests for preventative measures against
+[Cross-site scripting (XSS)](/en-US/docs/Glossary/Cross-site_scripting) attacks,
+[manipulator-in-the-middle (MiTM)](/en-US/docs/Glossary/MitM) attacks,
+cross-domain information leakage, insecure
+[cookies](/en-US/docs/Web/HTTP/Cookies),
+[Content Delivery Network](/en-US/docs/Glossary/CDN) (CDN) compromises, and
+improperly issued certificates.
+
+However, it does not test for outdated software versions,
+[SQL injection](/en-US/docs/Glossary/SQL_Injection) vulnerabilities, vulnerable
+content management system plugins, improper creation or storage of passwords,
+and more. These are just as important as the issues Observatory _does_ test for,
+and site operators should not be neglectful of them simply because they score
+well on Observatory.
+
+## Can I scan non-websites, such as API endpoints?
+
+The HTTP Observatory is designed for scanning websites, not API endpoints. It
+can be used for API endpoints, and the security headers expected by Observatory
+shouldn't cause any negative impact for APIs that return exclusively data, such
+as JSON or XML. However, the results may not accurately reflect the security
+posture of the API. API endpoints generally should only be accessible over
+HTTPS. The recommended configuration for API endpoints is:
+
+```http
+Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
+Strict-Transport-Security: max-age=63072000
+X-Content-Type-Options: nosniff
+```
+
+## Can other people see my test results?
+
+Anyone can choose to scan any domain, and the scan history for each domain is
+public. However, HTTP Observatory does not store user data related to each scan.
+In the old version of HTTP Observatory, users could choose to set their scan to
+"public" or keep it private (the default), and there was a "recent scans" list
+where domain names were listed. "Recent scans" was the main feature that users
+would potentially wish to opt-out from, but it is no longer supported, hence
+there is now no reason to provide the "public" flag.
+
+## When did the move occur?
+
+HTTP Observatory was launched on MDN on July 2, 2024, with the existing Mozilla
+Observatory site redirecting to it. Other tools like TLS Observatory, SSH
+Observatory, and Third-party tests have been deprecated, and will be sunset in
+September 2024.
+
+> **Note:** Historic scan data has been preserved, and is included in the
+> provided scan history for each domain.
+
+## What has changed after the migration?
+
+The MDN team has:
+
+- Updated the user experience to improve the site's look and make it easier to
+ use. For example, the recommendations highlighted by the test results are all
+ shown together, instead of one at a time.
+- Updated the
+ [accompanying documentation](/en-US/docs/Web/Security/Practical_implementation_guides#content_security_fundamentals)
+ to bring it up to date and improve legibility.
+- Changed the "rescan" checkbox and its underlying mechanics:
+ - There is no longer a rescan parameter.
+ - A site can only be scanned and a new result returned every 60 seconds.
+ - Deep-linking into a report initiates a rescan if the previous scan data is
+ older than 24 hours.
+- Updated the
+ [tests](/en-US/observatory/docs/tests_and_scoring#tests-and-score-modifiers)
+ to bring them up-to-date with latest security best practices:
+ - Removed the out-of-date `X-XSS-Protection` test.
+ - Removed the out-of-date Flash and Silverlight (`clientaccesspolicy.xml` and
+ `crossdomain.xml`) embedding tests.
+ - Added a
+ [`Cross-Origin-Resource-Policy`](/en-US/docs/Web/HTTP/Headers/Cross-Origin-Resource-Policy)
+ (CORP) test.
+ - Updated the
+ [`Referrer-Policy`](/en-US/docs/Web/HTTP/Headers/Referrer-Policy) test to
+ update the score modifier for `referrer-policy-unsafe` and remove the
+ `referrer-policy-no-referrer-when-downgrade` result.
+
+## Has the HTTP Observatory API been updated to use the new tests?
+
+Not yet. The API will continue using the old test infrastructure for a while,
+therefore you will see some small differences between test scores returned by
+the API and the website. The API will be updated to use the new tests in a
+near-future iteration.
+
+## Does the new HTTP Observatory provide specific TLS and certificate data?
+
+The previous Observatory site included specific results tabs containing TLS and
+certificate analysis data. The new one does not, and there are currently no
+plans to include these features: it provides a clear focus on HTTP data.
+
+## (Redirection) What is the HTTP redirection test assessing?
+
+This test is checking whether your web server is making its
+[initial redirection from HTTP to HTTPS](/en-US/docs/Web/Security/Practical_implementation_guides/TLS#http_redirection),
+on the same hostname, before doing any further redirections. This allows the
+HTTP
+[`Strict-Transport-Security`](/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)
+(HSTS) header to be applied properly.
+
+For example, this redirection order is correct:
+
+`http://example.com` → `https://example.com` → `https://www.example.com`
+
+An incorrect (and penalized) redirection looks like this:
+
+`http://example.com` → `https://www.example.com`
+
+## (X-Frame-Options) What if I want to allow my site to be framed?
+
+As long as you are explicit about your preference by using the
+[`Content-Security-Policy`](/en-US/docs/Web/HTTP/Headers/Content-Security-Policy)
+[`frame-ancestors`](/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors)
+directive, you will pass the
+[`X-Frame-Options`](/en-US/docs/Web/HTTP/Headers/X-Frame-Options) test. For
+example, to allow your site to be framed by any HTTPS site:
+
+```http
+Content-Security-Policy: frame-ancestors https:
+```
diff --git a/copy/observatory/tests_and_scoring.md b/copy/observatory/tests_and_scoring.md
new file mode 100644
index 000000000000..ef75cc919b40
--- /dev/null
+++ b/copy/observatory/tests_and_scoring.md
@@ -0,0 +1,60 @@
+---
+title: Tests & Scoring
+---
+
+# HTTP Observatory Scoring Methodology
+
+It is difficult to assign an objective value to a subjective question such as
+"How bad is not implementing HTTP Strict Transport Security?" In addition, what
+may be unnecessary for one site — such as implementing Content Security Policy —
+might mitigate important risks for another. The scores and grades offered by the
+Mozilla Observatory are designed to alert developers when they're not taking
+advantage of the latest web security features. Individual developers will need
+to determine which ones are appropriate for their sites.
+
+This page outlines the scoring methodology and grading system Observatory uses,
+before listing all of the specific tests along with their score modifiers.
+
+## Scoring Methodology
+
+All websites start with a baseline score of 100, which is then modified with
+penalties and/or bonuses resulting from the tests. The scoring is done across
+two rounds:
+
+1. The baseline score has the penalty points deducted from it.
+2. If the resulting score is 90 (A) or greater, the bonuses are then added to
+ it. You can think of the bonuses as extra credit for going above and beyond
+ the call of duty in defending your website.
+
+Each site tested by Observatory is awarded a grade based on its final score
+after the two rounds. The minimum score is 0, and the highest possible score in
+the HTTP Observatory is currently 135.
+
+## Grading Chart
+
+| Scoring Range | Grade |
+| :-----------: | :-----------: |
+| 100+ | A+ |
+| 90-99 | A |
+| 85-89 | A- |
+| 80-84 | B+ |
+| 70-79 | B |
+| 65-69 | B- |
+| 60-64 | C+ |
+| 50-59 | C |
+| 45-49 | C- |
+| 40-44 | D+ |
+| 30-39 | D |
+| 25-29 | D- |
+| 0-24 | F |
+
+The letter grade ranges and modifiers are essentially arbitrary, however, they
+are based on feedback from industry professionals on how important passing or
+failing a given test is likely to be.
+
+## Tests and Score Modifiers
+
+> **Note:** Over time, the modifiers may change as baselines shift or new
+> cutting-edge defensive security technologies are created. The bonuses
+> (positive modifiers) are specifically designed to encourage people to adopt
+> new security technologies or tackle difficult implementation challenges.
diff --git a/docs/envvars.md b/docs/envvars.md
index e7b81843a84c..0d70412bb960 100644
--- a/docs/envvars.md
+++ b/docs/envvars.md
@@ -352,3 +352,9 @@ included this value for `geo.country_iso`.
- Sets the host name for the playground iframe. Set this to `localhost:5042`
when working on playground functionality.
+
+### REACT_APP_OBSERVATORY_API_URL
+
+**Default: `https://observatory-api.mdn.allizom.net`**
+
+- Base url for the Observatory API server.
diff --git a/kumascript/macros/HTTPSidebar.ejs b/kumascript/macros/HTTPSidebar.ejs
index 1c2c268c0807..eaa828b301f9 100644
--- a/kumascript/macros/HTTPSidebar.ejs
+++ b/kumascript/macros/HTTPSidebar.ejs
@@ -325,8 +325,8 @@ var text = mdn.localStringMap({
<%-web.smartLink(`/${locale}/docs/Web/HTTP/Headers/X-Content-Type-Options`, null, "X-Content-Type-Options")%>
<%-web.smartLink(`/${locale}/docs/Web/HTTP/Headers/X-Frame-Options`, null, "X-Frame-Options")%>
<%-web.smartLink(`/${locale}/docs/Web/HTTP/Headers/X-XSS-Protection`, null, "X-XSS-Protection")%>
- Mozilla web security guidelines
- Mozilla Observatory
+ <%-web.smartLink(`/${locale}/docs/Web/Security/Practical_implementation_guides`)%>
+ HTTP Observatory
diff --git a/libs/constants/index.d.ts b/libs/constants/index.d.ts
index ce019a3de91d..2df1cccf889c 100644
--- a/libs/constants/index.d.ts
+++ b/libs/constants/index.d.ts
@@ -27,3 +27,5 @@ export const HTML_FILENAME: string;
export const MARKDOWN_FILENAME: string;
export const VALID_MIME_TYPES: Set;
export const MAX_COMPRESSION_DIFFERENCE_PERCENTAGE: number;
+export const OBSERVATORY_TITLE: string;
+export const OBSERVATORY_TITLE_FULL: string;
diff --git a/libs/constants/index.js b/libs/constants/index.js
index 8f52bdf0531e..2bd1b51a603a 100644
--- a/libs/constants/index.js
+++ b/libs/constants/index.js
@@ -111,6 +111,10 @@ export const CSP_DIRECTIVES = {
"https://*.analytics.google.com",
"https://*.googletagmanager.com",
+ // Observatory
+ "https://observatory-api.mdn.allizom.net",
+ "https://observatory-api.mdn.mozilla.net",
+
"stats.g.doubleclick.net",
"https://api.stripe.com",
],
@@ -275,6 +279,8 @@ export const VALID_FLAW_CHECKS = new Set([
export const MDN_PLUS_TITLE = "MDN Plus";
export const CURRICULUM_TITLE = "MDN Curriculum";
+export const OBSERVATORY_TITLE = "HTTP Observatory";
+export const OBSERVATORY_TITLE_FULL = "HTTP Observatory | MDN";
// -------
// content
diff --git a/libs/types/hydration.ts b/libs/types/hydration.ts
index 27396f4e52d0..6d50f30f306f 100644
--- a/libs/types/hydration.ts
+++ b/libs/types/hydration.ts
@@ -6,6 +6,7 @@ interface HydrationData {
blogMeta?: BlogPostMetadata | null;
pageNotFound?: boolean;
pageTitle?: any;
+ pageDescription?: string;
possibleLocales?: any;
locale?: any;
noIndexing?: boolean;
diff --git a/ssr/render.ts b/ssr/render.ts
index a9c8254312d7..7cbdba439454 100644
--- a/ssr/render.ts
+++ b/ssr/render.ts
@@ -105,6 +105,7 @@ export default function render(
pageNotFound = false,
hyData = null,
pageTitle = null,
+ pageDescription = "",
possibleLocales = null,
locale = null,
noIndexing = false,
@@ -118,7 +119,6 @@ export default function render(
const canonicalURL = `${BASE_URL}${url}`;
- let pageDescription = "";
let escapedPageTitle = htmlEscape(pageTitle);
const hydrationData: HydrationData = { url };