snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
86
docs/site/src/components/AttestationStatus/index.tsx
Normal file
86
docs/site/src/components/AttestationStatus/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
// Compliance / authorization status badge for framework pages
|
||||
// (FedRAMP, DoD IL, DISA STIG, HIPAA, FINRA, CMMC, Common Criteria…).
|
||||
//
|
||||
// Status semantics:
|
||||
// authorized — formally authorized / certified; ATO or equivalent in place.
|
||||
// in-process — actively pursuing authorization; documented gap exists.
|
||||
// roadmap — committed for future authorization; no work in flight yet.
|
||||
// not-pursued — not on the roadmap; framework documented for reference only.
|
||||
//
|
||||
// Usage:
|
||||
// <AttestationStatus framework="FedRAMP Moderate" status="in-process" />
|
||||
// <AttestationStatus framework="FIPS 140-3" status="authorized" certificate="CMVP #4735" />
|
||||
// <AttestationStatus framework="Common Criteria" status="not-pursued" />
|
||||
|
||||
type Status = 'authorized' | 'in-process' | 'roadmap' | 'not-pursued';
|
||||
|
||||
const STATUS_LABEL: Record<Status, string> = {
|
||||
'authorized': 'Authorized',
|
||||
'in-process': 'In Process',
|
||||
'roadmap': 'Roadmap',
|
||||
'not-pursued': 'Not Pursued',
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<Status, string> = {
|
||||
'authorized': 'authorized',
|
||||
'in-process': 'inProcess',
|
||||
'roadmap': 'roadmap',
|
||||
'not-pursued': 'notPursued',
|
||||
};
|
||||
|
||||
const KNOWN_STATUSES = Object.keys(STATUS_LABEL) as Status[];
|
||||
|
||||
export default function AttestationStatus({
|
||||
framework,
|
||||
status,
|
||||
certificate,
|
||||
asOf,
|
||||
detailsHref,
|
||||
}: {
|
||||
framework: string;
|
||||
status: string;
|
||||
certificate?: string;
|
||||
asOf?: string;
|
||||
detailsHref?: string;
|
||||
}) {
|
||||
if (!framework) {
|
||||
return (
|
||||
<aside className={`${styles.box} ${styles.unknown}`} role="note">
|
||||
<code><AttestationStatus/></code> requires a <code>framework</code> prop.
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
if (!(KNOWN_STATUSES as string[]).includes(status)) {
|
||||
return (
|
||||
<aside className={`${styles.box} ${styles.unknown}`} role="note">
|
||||
Unknown attestation status: <code>{status}</code>. Valid:{' '}
|
||||
<code>{KNOWN_STATUSES.join(', ')}</code>.
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const s = status as Status;
|
||||
return (
|
||||
<aside
|
||||
className={`${styles.box} ${styles[STATUS_CLASS[s]]}`}
|
||||
role="note"
|
||||
aria-label={`${framework} attestation status`}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.framework}>{framework}</span>
|
||||
<span className={styles.status} data-status={s}>{STATUS_LABEL[s]}</span>
|
||||
</div>
|
||||
{(certificate || asOf || detailsHref) && (
|
||||
<div className={styles.meta}>
|
||||
{certificate && <span><strong>Certificate:</strong> {certificate}</span>}
|
||||
{asOf && <span><strong>As of:</strong> {asOf}</span>}
|
||||
{detailsHref && <Link href={detailsHref}>Attestation details</Link>}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
77
docs/site/src/components/AttestationStatus/styles.module.css
Normal file
77
docs/site/src/components/AttestationStatus/styles.module.css
Normal file
@@ -0,0 +1,77 @@
|
||||
/* AttestationStatus — framework header + status pill + optional metadata.
|
||||
* Distinct from edition/deployment badges: this is a full-width block at
|
||||
* the top of compliance-framework pages, not an inline chip. */
|
||||
|
||||
.box {
|
||||
display: block;
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
|
||||
border-left: 4px solid var(--mm-color-denim, #1E325C);
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .box {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.framework {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid currentColor;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status[data-status='authorized'] { color: #1d6f42; background: rgba(29, 111, 66, 0.08); }
|
||||
.status[data-status='in-process'] { color: #8a5d00; background: rgba(255, 188, 31, 0.18); }
|
||||
.status[data-status='roadmap'] { color: #1E325C; background: rgba(30, 50, 92, 0.08); }
|
||||
.status[data-status='not-pursued'] { color: #6c757d; background: rgba(108, 117, 125, 0.10); }
|
||||
|
||||
[data-theme='dark'] .status[data-status='authorized'] { color: #6dd49b; }
|
||||
[data-theme='dark'] .status[data-status='in-process'] { color: #FFBC1F; }
|
||||
[data-theme='dark'] .status[data-status='roadmap'] { color: #9eb1da; }
|
||||
[data-theme='dark'] .status[data-status='not-pursued'] { color: #b0b6bd; }
|
||||
|
||||
/* Variant left-borders per status, for at-a-glance scanning. */
|
||||
.authorized { border-left-color: #1d6f42; }
|
||||
.inProcess { border-left-color: #FFBC1F; }
|
||||
.roadmap { border-left-color: #1E325C; }
|
||||
.notPursued { border-left-color: #6c757d; }
|
||||
|
||||
.meta {
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.88rem;
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
}
|
||||
|
||||
.meta strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.unknown {
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
42
docs/site/src/components/Callout/index.tsx
Normal file
42
docs/site/src/components/Callout/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type Kind = 'note' | 'tip' | 'important' | 'attention' | 'warning' | 'security';
|
||||
|
||||
const COPY: Record<Kind, {label: string; icon: string}> = {
|
||||
note: {label: 'Note', icon: 'i'},
|
||||
tip: {label: 'Tip', icon: '+'},
|
||||
important: {label: 'Important', icon: '!'},
|
||||
attention: {label: 'Attention', icon: '!'},
|
||||
warning: {label: 'Warning', icon: '!'},
|
||||
security: {label: 'Security', icon: 'S'},
|
||||
};
|
||||
|
||||
export default function Callout({
|
||||
kind = 'note',
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
kind?: Kind;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const meta = COPY[kind];
|
||||
return (
|
||||
<aside className={`${styles.callout} ${styles[kind]}`} role="note">
|
||||
<div className={styles.header}>
|
||||
<span className={styles.icon} aria-hidden>{meta.icon}</span>
|
||||
<span className={styles.label}>{title ?? meta.label}</span>
|
||||
</div>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// Convenience wrappers used like <Note>...</Note> in MDX.
|
||||
export const Note = (p: {title?: string; children: React.ReactNode}) => <Callout kind="note" {...p} />;
|
||||
export const Tip = (p: {title?: string; children: React.ReactNode}) => <Callout kind="tip" {...p} />;
|
||||
export const Important = (p: {title?: string; children: React.ReactNode}) => <Callout kind="important" {...p} />;
|
||||
export const Attention = (p: {title?: string; children: React.ReactNode}) => <Callout kind="attention" {...p} />;
|
||||
export const Warning = (p: {title?: string; children: React.ReactNode}) => <Callout kind="warning" {...p} />;
|
||||
export const Security = (p: {title?: string; children: React.ReactNode}) => <Callout kind="security" {...p} />;
|
||||
121
docs/site/src/components/Callout/styles.module.css
Normal file
121
docs/site/src/components/Callout/styles.module.css
Normal file
@@ -0,0 +1,121 @@
|
||||
/* Branded callouts, styled to match the compact Docusaurus admonitions used
|
||||
* elsewhere in the docs: a tinted panel with a left accent bar and a small
|
||||
* icon + label header. */
|
||||
|
||||
.callout {
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.9rem 1.1rem 1rem;
|
||||
border: 1px solid var(--callout-border);
|
||||
border-left: 4px solid var(--callout-accent);
|
||||
border-radius: 4px;
|
||||
background: var(--callout-bg);
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
color: var(--callout-accent);
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
color: var(--callout-accent);
|
||||
}
|
||||
|
||||
.content > :first-child { margin-top: 0; }
|
||||
.content > :last-child { margin-bottom: 0; }
|
||||
.content p { margin: 0.5rem 0; line-height: 1.6; }
|
||||
|
||||
/* Kind variants: each sets an accent color plus a tint of the same hue for
|
||||
* the panel background. */
|
||||
|
||||
.note {
|
||||
--callout-accent: #2E4B87;
|
||||
--callout-bg: var(--mm-denim-50);
|
||||
--callout-border: var(--mm-denim-100);
|
||||
}
|
||||
[data-theme='dark'] .note {
|
||||
--callout-accent: var(--mm-denim-200);
|
||||
--callout-bg: var(--mm-denim-700);
|
||||
--callout-border: var(--mm-denim-400);
|
||||
}
|
||||
|
||||
.tip {
|
||||
--callout-accent: #1F5A39;
|
||||
--callout-bg: #EAF5EE;
|
||||
--callout-border: #C3E0CE;
|
||||
}
|
||||
[data-theme='dark'] .tip {
|
||||
--callout-accent: #7FCC9E;
|
||||
--callout-bg: #14442C;
|
||||
--callout-border: #2C7A4F;
|
||||
}
|
||||
|
||||
.important {
|
||||
--callout-accent: #8A6200;
|
||||
--callout-bg: var(--mm-marigold-100);
|
||||
--callout-border: var(--mm-marigold-300);
|
||||
}
|
||||
[data-theme='dark'] .important {
|
||||
--callout-accent: var(--mm-marigold-300);
|
||||
--callout-bg: #4A3A10;
|
||||
--callout-border: #7A6220;
|
||||
}
|
||||
|
||||
/* Attention outranks Important: the reader loses something by skipping it.
|
||||
* Brighter than Security's maroon so the two stay tellable apart. */
|
||||
.attention {
|
||||
--callout-accent: #C8102E;
|
||||
--callout-bg: #FDECEF;
|
||||
--callout-border: #F5C6CE;
|
||||
}
|
||||
[data-theme='dark'] .attention {
|
||||
--callout-accent: #F4A0AB;
|
||||
--callout-bg: #4E0F1B;
|
||||
--callout-border: #85202F;
|
||||
}
|
||||
|
||||
.warning {
|
||||
--callout-accent: #6B4F2E;
|
||||
--callout-bg: #F6F1E9;
|
||||
--callout-border: #E0D5C1;
|
||||
}
|
||||
[data-theme='dark'] .warning {
|
||||
--callout-accent: var(--mm-color-sand);
|
||||
--callout-bg: #3A2F22;
|
||||
--callout-border: var(--mm-color-bark);
|
||||
}
|
||||
|
||||
.security {
|
||||
--callout-accent: #8B1F1F;
|
||||
--callout-bg: #FBEDED;
|
||||
--callout-border: #EFCFCF;
|
||||
}
|
||||
[data-theme='dark'] .security {
|
||||
--callout-accent: #E9A0A0;
|
||||
--callout-bg: #4A1F1F;
|
||||
--callout-border: #7A3535;
|
||||
}
|
||||
79
docs/site/src/components/CardGrid/index.tsx
Normal file
79
docs/site/src/components/CardGrid/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type Card = {
|
||||
title: string;
|
||||
description?: string;
|
||||
to: string;
|
||||
icon?: string; // CompassIcon name
|
||||
meta?: string; // small annotation (e.g., "549 endpoints")
|
||||
};
|
||||
|
||||
/**
|
||||
* Grid of navigation cards for landing pages. Replaces bulleted lists
|
||||
* of links — each card is a discrete navigation surface with title,
|
||||
* description, and optional Compass icon + meta.
|
||||
*
|
||||
* The `compact` variant renders the same cards as a wrapping row of
|
||||
* low-weight chips, for secondary destinations that need to stay
|
||||
* scannable without competing with a primary card grid above them.
|
||||
*
|
||||
* Usage:
|
||||
* <CardGrid cards={[
|
||||
* {title: 'Examples', icon: 'channels', to: '/api/examples',
|
||||
* description: 'Quick-start with curl.', meta: '6 examples'},
|
||||
* {title: 'Reference', icon: 'boards', to: '/api/reference/login',
|
||||
* description: 'Every endpoint, grouped by resource.', meta: '549 endpoints'},
|
||||
* ]} />
|
||||
*
|
||||
* <CardGrid variant="compact" cards={[
|
||||
* {title: 'Compliance Officers', to: '/for/compliance-officer',
|
||||
* description: 'Audit evidence, exports.'},
|
||||
* ]} />
|
||||
*/
|
||||
export default function CardGrid({
|
||||
cards,
|
||||
columns = 3,
|
||||
variant = 'default',
|
||||
}: {
|
||||
cards: Card[];
|
||||
columns?: 2 | 3 | 4;
|
||||
variant?: 'default' | 'compact';
|
||||
}) {
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div className={styles.chipRow}>
|
||||
{cards.map((c, i) => (
|
||||
<Link key={`${c.to}-${i}`} to={c.to} className={styles.chip}>
|
||||
<span className={styles.chipBody}>
|
||||
<span className={styles.chipTitle}>{c.title}</span>
|
||||
{c.description && <span className={styles.chipDescription}>{c.description}</span>}
|
||||
</span>
|
||||
<span className={styles.chipArrow} aria-hidden>→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// Wrapper establishes a size query container for the column-count
|
||||
// breakpoints in styles.module.css.
|
||||
<div className={styles.gridContainer}>
|
||||
<div className={`${styles.grid} ${styles[`cols${columns}`]}`}>
|
||||
{cards.map((c, i) => (
|
||||
<Link key={`${c.to}-${i}`} to={c.to} className={styles.card}>
|
||||
{c.icon && <span className={`${styles.icon} mm-compass-${c.icon}`} aria-hidden />}
|
||||
<div className={styles.body}>
|
||||
<div className={styles.title}>{c.title}</div>
|
||||
<p className={styles.description}>{c.description}</p>
|
||||
{c.meta && <span className={styles.meta}>{c.meta}</span>}
|
||||
</div>
|
||||
<span className={styles.arrow} aria-hidden>→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
docs/site/src/components/CardGrid/styles.module.css
Normal file
261
docs/site/src/components/CardGrid/styles.module.css
Normal file
@@ -0,0 +1,261 @@
|
||||
.gridContainer {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
/* `repeat(auto-fit, minmax(200px, 1fr))` packed 4 cards into one tall, narrow
|
||||
* row at typical article widths. Step the column count down explicitly
|
||||
* instead, halving 4 to 2x2 and 3 to 2 before dropping to a single column. */
|
||||
.cols2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cols3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.cols4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
|
||||
@container (max-width: 1080px) {
|
||||
.cols4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@container (max-width: 820px) {
|
||||
.cols3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@container (max-width: 540px) {
|
||||
.cols2,
|
||||
.cols3,
|
||||
.cols4 { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
/* Fallback for browsers without container query support — viewport width is
|
||||
* a reasonable stand-in since the doc sidebar collapses below 997px. */
|
||||
@supports not (container-type: inline-size) {
|
||||
@media (max-width: 996px) {
|
||||
.cols3,
|
||||
.cols4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.cols2,
|
||||
.cols3,
|
||||
.cols4 { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.9rem;
|
||||
padding: 1.4rem 1.6rem;
|
||||
background: var(--mm-bg-surface);
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: 0 1px 2px rgba(30, 50, 92, 0.05);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 32px -18px rgba(30, 50, 92, 0.3);
|
||||
border-color: var(--mm-border-strong);
|
||||
background: var(--mm-bg-subtle);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
/* --mm-focus-ring (marigold) is ~1.7:1 against the white card surface,
|
||||
* well under the 3:1 WCAG 1.4.11 minimum for non-text UI indicators.
|
||||
* Use denim instead, which passes on both the light and dark surface. */
|
||||
.card:focus-visible {
|
||||
outline: 2px solid var(--mm-color-denim);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .card:focus-visible {
|
||||
outline-color: var(--mm-denim-300, #8499C5);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .card {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
box-shadow: none;
|
||||
}
|
||||
[data-theme='dark'] .card:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: var(--mm-denim-300);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
background: var(--mm-bg-subtle);
|
||||
color: var(--mm-color-denim);
|
||||
border-radius: 50%;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.card:hover .icon {
|
||||
background: var(--mm-color-denim);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .icon {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--mm-denim-200);
|
||||
}
|
||||
[data-theme='dark'] .card:hover .icon {
|
||||
background: var(--mm-color-white);
|
||||
color: var(--mm-color-denim);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
/* Mixed-case, multi-word labels (e.g. "For End Users") read as too
|
||||
* heavy/shouty in Archivo Black at 900 — same issue already fixed for
|
||||
* h1-h4 — so use the sans face at a lighter bold weight instead. */
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: inline-block;
|
||||
font-family: var(--mm-font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--mm-marigold-700);
|
||||
background: var(--mm-marigold-100);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .meta {
|
||||
color: var(--mm-color-marigold);
|
||||
background: rgba(255, 188, 31, 0.12);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 1.4rem;
|
||||
right: 1.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 50%;
|
||||
color: var(--mm-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
transition: transform 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.card:hover .arrow {
|
||||
background: var(--mm-bg-page);
|
||||
color: var(--mm-color-denim);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .card:hover .arrow {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
/* Compact variant — secondary destinations that sit under a primary
|
||||
* card grid. Same affordances (surface, hover, arrow) at roughly half
|
||||
* the visual weight, so they read as a second tier rather than a
|
||||
* competing one. */
|
||||
.chipRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin: 0.75rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: var(--mm-bg-surface);
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.chip:hover {
|
||||
background: var(--mm-bg-subtle);
|
||||
border-color: var(--mm-border-strong);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--mm-color-denim);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .chip {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
[data-theme='dark'] .chip:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: var(--mm-denim-300);
|
||||
}
|
||||
[data-theme='dark'] .chip:focus-visible {
|
||||
outline-color: var(--mm-denim-300, #8499C5);
|
||||
}
|
||||
|
||||
.chipBody {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chipTitle {
|
||||
display: block;
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
.chipDescription {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.chipArrow {
|
||||
flex: 0 0 auto;
|
||||
color: var(--mm-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
transition: transform 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.chip:hover .chipArrow {
|
||||
color: var(--mm-color-denim);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .chip:hover .chipArrow {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
34
docs/site/src/components/CompassIcon/index.tsx
Normal file
34
docs/site/src/components/CompassIcon/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
/**
|
||||
* Brand-aligned icon. Ports the Hugo `compass-icon` shortcode used in
|
||||
* mattermost-developer-documentation. Backed by SVGs in
|
||||
* docs-site/static/img/icons/. During phase 4 the full Compass icon
|
||||
* library is bulk-imported (see PLAN.md §11.2 Week 1).
|
||||
*
|
||||
* Usage:
|
||||
* <CompassIcon name="channels" size="md" tone="denim" />
|
||||
*/
|
||||
export default function CompassIcon({
|
||||
name,
|
||||
size = 'sm',
|
||||
tone = 'denim',
|
||||
alt = '',
|
||||
}: {
|
||||
name: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
tone?: 'denim' | 'marigold' | 'inherit';
|
||||
alt?: string;
|
||||
}) {
|
||||
const src = useBaseUrl(`/img/icons/${name}.svg`);
|
||||
return (
|
||||
<span
|
||||
className={`${styles.icon} ${styles[size]} ${styles[tone]}`}
|
||||
role={alt ? 'img' : 'presentation'}
|
||||
aria-label={alt || undefined}
|
||||
style={{maskImage: `url(${src})`, WebkitMaskImage: `url(${src})`}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
docs/site/src/components/CompassIcon/styles.module.css
Normal file
20
docs/site/src/components/CompassIcon/styles.module.css
Normal file
@@ -0,0 +1,20 @@
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: -0.15em;
|
||||
background-color: currentColor;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
-webkit-mask-size: contain;
|
||||
mask-position: center;
|
||||
-webkit-mask-position: center;
|
||||
}
|
||||
|
||||
.xs { width: 0.85em; height: 0.85em; }
|
||||
.sm { width: 1em; height: 1em; }
|
||||
.md { width: 1.4em; height: 1.4em; }
|
||||
.lg { width: 2em; height: 2em; }
|
||||
|
||||
.denim { color: var(--mm-color-denim); }
|
||||
.marigold { color: var(--mm-color-marigold); }
|
||||
.inherit { color: inherit; }
|
||||
369
docs/site/src/components/DeploymentArchitectureBuilder/data.ts
Normal file
369
docs/site/src/components/DeploymentArchitectureBuilder/data.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
// Data tables for the Deployment Architecture builder.
|
||||
//
|
||||
// Tier sizing follows the Mattermost scaling guide. Provider SKUs are
|
||||
// illustrative starting points — verify with current pricing and any
|
||||
// regional / compliance constraints before committing to a procurement
|
||||
// decision.
|
||||
|
||||
export type Tier = {
|
||||
id: string;
|
||||
label: string;
|
||||
seats: string;
|
||||
description: string;
|
||||
appNodes: number;
|
||||
appCpu: number;
|
||||
appRam: number;
|
||||
dbReplicas: number;
|
||||
redis: boolean;
|
||||
search: 'none' | 'optional' | 'recommended' | 'required';
|
||||
searchNodes: number;
|
||||
loadBalancer: 'none' | 'single' | 'cluster';
|
||||
storage: 'local' | 'object';
|
||||
ha: boolean;
|
||||
};
|
||||
|
||||
export const TIERS: Tier[] = [
|
||||
{
|
||||
id: 'trial',
|
||||
label: 'Trial / Pilot',
|
||||
seats: '≤ 200 users',
|
||||
description: 'Single host for evaluation. Mattermost and PostgreSQL co-located; no separate proxy or cache.',
|
||||
appNodes: 1,
|
||||
appCpu: 2,
|
||||
appRam: 4,
|
||||
dbReplicas: 0,
|
||||
redis: false,
|
||||
search: 'none',
|
||||
searchNodes: 0,
|
||||
loadBalancer: 'none',
|
||||
storage: 'local',
|
||||
ha: false,
|
||||
},
|
||||
{
|
||||
id: 'small',
|
||||
label: 'Small',
|
||||
seats: '≤ 2,000 users',
|
||||
description: 'Separate app and database hosts. NGINX reverse proxy. Single Mattermost node — restart causes a brief outage.',
|
||||
appNodes: 1,
|
||||
appCpu: 4,
|
||||
appRam: 16,
|
||||
dbReplicas: 0,
|
||||
redis: false,
|
||||
search: 'optional',
|
||||
searchNodes: 1,
|
||||
loadBalancer: 'single',
|
||||
storage: 'object',
|
||||
ha: false,
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
label: 'Medium',
|
||||
seats: '≤ 10,000 users',
|
||||
description: 'High availability: 2-node Mattermost cluster behind a load balancer. PostgreSQL with read replica.',
|
||||
appNodes: 2,
|
||||
appCpu: 8,
|
||||
appRam: 32,
|
||||
dbReplicas: 1,
|
||||
redis: false,
|
||||
search: 'recommended',
|
||||
searchNodes: 1,
|
||||
loadBalancer: 'single',
|
||||
storage: 'object',
|
||||
ha: true,
|
||||
},
|
||||
{
|
||||
id: 'large',
|
||||
label: 'Large',
|
||||
seats: '≤ 25,000 users',
|
||||
description: '4-node Mattermost cluster. PostgreSQL primary + 2 read replicas. Dedicated Elasticsearch / OpenSearch cluster for message search.',
|
||||
appNodes: 4,
|
||||
appCpu: 16,
|
||||
appRam: 64,
|
||||
dbReplicas: 2,
|
||||
redis: false,
|
||||
search: 'required',
|
||||
searchNodes: 3,
|
||||
loadBalancer: 'cluster',
|
||||
storage: 'object',
|
||||
ha: true,
|
||||
},
|
||||
{
|
||||
id: 'xlarge',
|
||||
label: 'Extra Large',
|
||||
seats: '≤ 50,000 users',
|
||||
description: '8-node Mattermost cluster. PostgreSQL primary + 2 replicas with connection pooler. OpenSearch cluster (5 nodes).',
|
||||
appNodes: 8,
|
||||
appCpu: 16,
|
||||
appRam: 64,
|
||||
dbReplicas: 2,
|
||||
redis: false,
|
||||
search: 'required',
|
||||
searchNodes: 5,
|
||||
loadBalancer: 'cluster',
|
||||
storage: 'object',
|
||||
ha: true,
|
||||
},
|
||||
{
|
||||
id: 'mega',
|
||||
label: 'Mega',
|
||||
seats: '≤ 100,000 users',
|
||||
description: '16-node Mattermost cluster. PostgreSQL with connection pooler + multiple read replicas. OpenSearch cluster (5+ data nodes). CDN in front of static assets.',
|
||||
appNodes: 16,
|
||||
appCpu: 32,
|
||||
appRam: 128,
|
||||
dbReplicas: 3,
|
||||
redis: false,
|
||||
search: 'required',
|
||||
searchNodes: 5,
|
||||
loadBalancer: 'cluster',
|
||||
storage: 'object',
|
||||
ha: true,
|
||||
},
|
||||
{
|
||||
id: 'hyperscale',
|
||||
label: 'Hyperscale',
|
||||
seats: '≤ 200,000 users',
|
||||
description: '32-node Mattermost cluster. Sharded PostgreSQL via connection pooler. Redis cluster (5+ nodes). OpenSearch cluster (10+ data nodes). Multi-region CDN.',
|
||||
appNodes: 32,
|
||||
appCpu: 32,
|
||||
appRam: 128,
|
||||
dbReplicas: 4,
|
||||
redis: true,
|
||||
search: 'required',
|
||||
searchNodes: 10,
|
||||
loadBalancer: 'cluster',
|
||||
storage: 'object',
|
||||
ha: true,
|
||||
},
|
||||
];
|
||||
|
||||
export type DeploymentType = 'vm' | 'kubernetes';
|
||||
export type Provider = 'aws' | 'azure' | 'gcp' | 'oci' | 'on-prem';
|
||||
|
||||
export const PROVIDERS: {id: Provider; label: string}[] = [
|
||||
{id: 'aws', label: 'AWS'},
|
||||
{id: 'azure', label: 'Azure'},
|
||||
{id: 'gcp', label: 'Google Cloud'},
|
||||
{id: 'oci', label: 'Oracle Cloud (OCI)'},
|
||||
{id: 'on-prem', label: 'On-prem / Other'},
|
||||
];
|
||||
|
||||
/* ============================ Brand short names ============================
|
||||
* Used in the diagram boxes — proper service product name per provider.
|
||||
* Longer descriptive labels live in the BOM under "Recommended size / SKU". */
|
||||
|
||||
export const DB_BRAND: Record<Provider, string> = {
|
||||
aws: 'Amazon RDS (PostgreSQL)',
|
||||
azure: 'Azure Database (PostgreSQL)',
|
||||
gcp: 'Cloud SQL (PostgreSQL)',
|
||||
oci: 'OCI Database (PostgreSQL)',
|
||||
'on-prem': 'PostgreSQL (self-managed)',
|
||||
};
|
||||
|
||||
export const REDIS_BRAND: Record<Provider, string> = {
|
||||
aws: 'Amazon ElastiCache (Redis)',
|
||||
azure: 'Azure Cache for Redis',
|
||||
gcp: 'Memorystore (Redis)',
|
||||
oci: 'OCI Cache (Redis)',
|
||||
'on-prem': 'Redis (self-managed)',
|
||||
};
|
||||
|
||||
export const SEARCH_BRAND: Record<Provider, string> = {
|
||||
aws: 'Amazon OpenSearch',
|
||||
azure: 'Self-hosted OpenSearch',
|
||||
gcp: 'Self-hosted OpenSearch',
|
||||
oci: 'OCI Search (OpenSearch)',
|
||||
'on-prem': 'OpenSearch (self-managed)',
|
||||
};
|
||||
|
||||
export const STORAGE_BRAND: Record<Provider, string> = {
|
||||
aws: 'Amazon S3',
|
||||
azure: 'Azure Blob Storage',
|
||||
gcp: 'Cloud Storage',
|
||||
oci: 'OCI Object Storage',
|
||||
'on-prem': 'MinIO (S3-compatible)',
|
||||
};
|
||||
|
||||
export const LB_BRAND_VM: Record<Provider, string> = {
|
||||
aws: 'AWS Application Load Balancer',
|
||||
azure: 'Azure Application Gateway',
|
||||
gcp: 'Google Cloud HTTPS LB',
|
||||
oci: 'OCI Flexible Load Balancer',
|
||||
'on-prem': 'NGINX / HAProxy',
|
||||
};
|
||||
|
||||
export const LB_BRAND_K8S: Record<Provider, string> = {
|
||||
aws: 'NGINX Ingress + AWS NLB',
|
||||
azure: 'NGINX Ingress + Azure LB',
|
||||
gcp: 'NGINX Ingress + GCP LB',
|
||||
oci: 'NGINX Ingress + OCI LB',
|
||||
'on-prem': 'NGINX Ingress Controller',
|
||||
};
|
||||
|
||||
/* ============================ DB instance sizing ============================ */
|
||||
|
||||
const DB_INSTANCE = {
|
||||
aws: {trial: 'co-located', small: 'db.m5.large', medium: 'db.m5.xlarge', large: 'db.m5.2xlarge', xlarge: 'db.m5.4xlarge', mega: 'db.r5.4xlarge', hyperscale: 'db.r5.8xlarge'},
|
||||
azure: {trial: 'co-located', small: 'D4ds_v4', medium: 'D8ds_v4', large: 'D16ds_v4', xlarge: 'D32ds_v4', mega: 'E32ds_v4', hyperscale: 'E48ds_v4'},
|
||||
gcp: {trial: 'co-located', small: 'db-n1-standard-4', medium: 'db-n1-standard-8', large: 'db-n1-standard-16', xlarge: 'db-n1-standard-32', mega: 'db-n1-highmem-32', hyperscale: 'db-n1-highmem-64'},
|
||||
oci: {trial: 'co-located', small: 'PostgreSQL.E4.4', medium: 'PostgreSQL.E4.8', large: 'PostgreSQL.E4.16', xlarge: 'PostgreSQL.E4.32', mega: 'PostgreSQL.E4.64', hyperscale: 'PostgreSQL.E4.96'},
|
||||
'on-prem': {trial: 'co-located', small: '4 vCPU / 16 GB', medium: '8 vCPU / 32 GB', large: '16 vCPU / 64 GB', xlarge: '32 vCPU / 128 GB', mega: '32 vCPU / 256 GB', hyperscale: '64 vCPU / 384 GB'},
|
||||
} as const;
|
||||
|
||||
const APP_SKU = {
|
||||
aws: {'2-4': 't3.medium', '4-16': 'm5.xlarge', '8-32': 'm5.2xlarge', '16-64': 'm5.4xlarge', '32-128': 'm6i.8xlarge'},
|
||||
azure: {'2-4': 'B2ms', '4-16': 'D4s_v3', '8-32': 'D8s_v3', '16-64': 'D16s_v3', '32-128': 'D32s_v3'},
|
||||
gcp: {'2-4': 'e2-medium', '4-16': 'e2-standard-4', '8-32': 'e2-standard-8', '16-64': 'n2-standard-16', '32-128': 'n2-standard-32'},
|
||||
oci: {'2-4': 'VM.Standard.E4.Flex (2 OCPU)', '4-16': 'VM.Standard.E4.Flex (4 OCPU)', '8-32': 'VM.Standard.E4.Flex (8 OCPU)', '16-64': 'VM.Standard.E4.Flex (16 OCPU)', '32-128': 'VM.Standard.E4.Flex (32 OCPU)'},
|
||||
'on-prem': {'2-4': '2 vCPU / 4 GB VM', '4-16': '4 vCPU / 16 GB VM', '8-32': '8 vCPU / 32 GB VM', '16-64': '16 vCPU / 64 GB VM', '32-128': '32 vCPU / 128 GB VM'},
|
||||
} as const;
|
||||
|
||||
function bucketFor(cpu: number): keyof typeof APP_SKU['aws'] {
|
||||
if (cpu <= 2) return '2-4';
|
||||
if (cpu <= 4) return '4-16';
|
||||
if (cpu <= 8) return '8-32';
|
||||
if (cpu <= 16) return '16-64';
|
||||
return '32-128';
|
||||
}
|
||||
|
||||
/* ============================ Bill of materials ============================ */
|
||||
|
||||
export type BomRow = {
|
||||
component: string;
|
||||
quantity: string;
|
||||
spec: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type BomOptions = {
|
||||
calls: boolean;
|
||||
push: boolean;
|
||||
};
|
||||
|
||||
export function bomFor(
|
||||
tier: Tier,
|
||||
deploymentType: DeploymentType,
|
||||
provider: Provider,
|
||||
opts: BomOptions,
|
||||
): BomRow[] {
|
||||
const rows: BomRow[] = [];
|
||||
const onPrem = provider === 'on-prem';
|
||||
const k8s = deploymentType === 'kubernetes';
|
||||
const bucket = bucketFor(tier.appCpu);
|
||||
const tierId = tier.id as keyof typeof DB_INSTANCE['aws'];
|
||||
|
||||
// Load balancer / ingress
|
||||
if (tier.loadBalancer !== 'none') {
|
||||
rows.push({
|
||||
component: k8s ? 'Ingress controller' : 'Load balancer',
|
||||
quantity: tier.loadBalancer === 'cluster' ? 'HA pair' : '1',
|
||||
spec: k8s ? LB_BRAND_K8S[provider] : LB_BRAND_VM[provider],
|
||||
notes: 'TLS terminated at the proxy. Mattermost behind, listening on HTTP/8065.',
|
||||
});
|
||||
}
|
||||
|
||||
// App / Mattermost
|
||||
rows.push({
|
||||
component: k8s ? 'Mattermost (Operator)' : 'Mattermost app server',
|
||||
quantity: `${tier.appNodes} node${tier.appNodes > 1 ? 's' : ''}`,
|
||||
spec: k8s
|
||||
? `${tier.appCpu} vCPU / ${tier.appRam} GB RAM per pod — worker nodes: ${APP_SKU[provider][bucket]}`
|
||||
: `${APP_SKU[provider][bucket]} (${tier.appCpu} vCPU / ${tier.appRam} GB RAM)`,
|
||||
notes: tier.ha
|
||||
? 'High availability — restarts and upgrades are non-disruptive.'
|
||||
: 'Single node — restarts cause a brief outage.',
|
||||
});
|
||||
|
||||
// Database
|
||||
rows.push({
|
||||
component: 'PostgreSQL (primary)',
|
||||
quantity: '1',
|
||||
spec: `${DB_BRAND[provider]} — ${DB_INSTANCE[provider][tierId]}`,
|
||||
notes: tier.id === 'trial' ? 'Co-located with the app for evaluation only.' : 'Separate host. PostgreSQL 14 minimum. App connects on TCP/5432.',
|
||||
});
|
||||
if (tier.dbReplicas > 0) {
|
||||
rows.push({
|
||||
component: 'PostgreSQL read replicas',
|
||||
quantity: `${tier.dbReplicas}`,
|
||||
spec: onPrem
|
||||
? 'Streaming replication, same size as primary'
|
||||
: `${DB_BRAND[provider]} read replica`,
|
||||
notes: 'Read queries (search, dashboards) offloaded from the primary.',
|
||||
});
|
||||
}
|
||||
|
||||
// Redis
|
||||
if (tier.redis) {
|
||||
rows.push({
|
||||
component: 'Redis cache',
|
||||
quantity: tier.appNodes >= 16 ? 'Cluster (5+ nodes)' : tier.appNodes >= 8 ? 'Cluster (3 nodes)' : '1 node',
|
||||
spec: REDIS_BRAND[provider],
|
||||
notes: 'Required for HA — coordinates sessions and cluster state across app nodes. App connects on TCP/6379.',
|
||||
});
|
||||
}
|
||||
|
||||
// Search
|
||||
if (tier.search !== 'none') {
|
||||
rows.push({
|
||||
component: 'Search index',
|
||||
quantity: `${tier.searchNodes} node${tier.searchNodes > 1 ? 's' : ''}`,
|
||||
spec: SEARCH_BRAND[provider],
|
||||
notes:
|
||||
tier.search === 'optional'
|
||||
? 'Optional at this size — message search will work via PostgreSQL but is slower above ~5,000 users. App connects on HTTPS/9200.'
|
||||
: tier.search === 'recommended'
|
||||
? 'Recommended — message search performance degrades on the DB alone at this scale. App connects on HTTPS/9200.'
|
||||
: 'Required — message search at this scale cannot run on PostgreSQL alone. App connects on HTTPS/9200.',
|
||||
});
|
||||
}
|
||||
|
||||
// File storage
|
||||
rows.push({
|
||||
component: 'File storage',
|
||||
quantity: '—',
|
||||
spec: tier.storage === 'local' ? 'Local disk on app host (trial only)' : STORAGE_BRAND[provider],
|
||||
notes: tier.storage === 'local'
|
||||
? 'Acceptable for trial only. Move to object storage before any HA or multi-node config.'
|
||||
: 'S3-compatible bucket. App connects on HTTPS/443. Backup + versioning recommended.',
|
||||
});
|
||||
|
||||
// Calls (RTCD) — optional
|
||||
if (opts.calls) {
|
||||
rows.push({
|
||||
component: 'Calls / RTCD service',
|
||||
quantity: tier.appNodes >= 8 ? '2-3 nodes (HA)' : '1 node',
|
||||
spec: 'Mattermost RTCD (real-time communications daemon) — small container or VM (2 vCPU / 4 GB)',
|
||||
notes:
|
||||
'Standalone service for audio + screen-share. Clients connect on UDP/8443 (and TCP/8443 fallback). Mattermost talks to RTCD on TCP/8045 for the API.',
|
||||
});
|
||||
rows.push({
|
||||
component: 'Calls plugin',
|
||||
quantity: '—',
|
||||
spec: 'Bundled Calls plugin enabled in System Console',
|
||||
notes: 'Required to drive the RTCD service. Configure provider URL pointing at RTCD endpoint.',
|
||||
});
|
||||
}
|
||||
|
||||
// Push notification proxy — optional
|
||||
if (opts.push) {
|
||||
rows.push({
|
||||
component: 'Push notification proxy',
|
||||
quantity: '1',
|
||||
spec: 'Mattermost Push Proxy (open source) — small single host (1 vCPU / 2 GB) or container',
|
||||
notes:
|
||||
'Mediates APNs (api.push.apple.com) and FCM (fcm.googleapis.com) on HTTPS/443. Use HPNS instead if you can reach Mattermost-managed; self-host for air-gapped / sovereign deployments.',
|
||||
});
|
||||
}
|
||||
|
||||
// Kubernetes-specific: cluster sizing note
|
||||
if (k8s) {
|
||||
rows.push({
|
||||
component: 'Kubernetes cluster',
|
||||
quantity: tier.appNodes <= 2 ? '3 worker nodes (min)' : `${tier.appNodes + 2}+ worker nodes`,
|
||||
spec: provider === 'aws' ? 'Amazon EKS' : provider === 'azure' ? 'Azure AKS' : provider === 'gcp' ? 'Google GKE' : provider === 'oci' ? 'Oracle OKE' : 'self-managed K8s',
|
||||
notes: 'Sized for app pods + system overhead. Mattermost Operator manages the app lifecycle.',
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
600
docs/site/src/components/DeploymentArchitectureBuilder/index.tsx
Normal file
600
docs/site/src/components/DeploymentArchitectureBuilder/index.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import React, {useState, useMemo} from 'react';
|
||||
import styles from './styles.module.css';
|
||||
import {
|
||||
TIERS,
|
||||
PROVIDERS,
|
||||
bomFor,
|
||||
DB_BRAND,
|
||||
REDIS_BRAND,
|
||||
SEARCH_BRAND,
|
||||
STORAGE_BRAND,
|
||||
LB_BRAND_VM,
|
||||
LB_BRAND_K8S,
|
||||
type Tier,
|
||||
type DeploymentType,
|
||||
type Provider,
|
||||
} from './data';
|
||||
|
||||
/**
|
||||
* Interactive deployment-architecture builder.
|
||||
*
|
||||
* Pick a scale tier, deployment type, hosting provider, and optional
|
||||
* services (Calls / Push). Renders an architecture diagram with explicit
|
||||
* per-connection port labels, plus a bill of materials and a network
|
||||
* flows table sized to those choices.
|
||||
*/
|
||||
|
||||
/* ============================ Diagram primitives ============================ */
|
||||
|
||||
function FlowDown({port}: {port: string}) {
|
||||
return (
|
||||
<div className={styles.flow} aria-hidden>
|
||||
<span className={styles.flowArrow}>↓</span>
|
||||
<span className={styles.flowPorts}>{port}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniFlow({port}: {port: string}) {
|
||||
return (
|
||||
<div className={styles.miniFlow} aria-hidden>
|
||||
<span className={styles.miniArrow}>↓</span> {port}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HFlow({direction, port}: {direction: 'left' | 'right'; port: string}) {
|
||||
const arrow = direction === 'left' ? '←' : '→';
|
||||
return (
|
||||
<div className={styles.hFlow} aria-hidden>
|
||||
<span className={styles.hArrow}>{arrow}</span>
|
||||
<span className={styles.hFlowPorts}>{port}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataItem({
|
||||
port,
|
||||
accent,
|
||||
label,
|
||||
subtitle,
|
||||
}: {
|
||||
port: string;
|
||||
accent: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.dataItem}>
|
||||
<MiniFlow port={port} />
|
||||
<div className={`${styles.box} ${accent}`}>
|
||||
<div className={styles.boxLabel}>{label}</div>
|
||||
<div className={styles.boxSub}>{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================ Architecture diagram ============================
|
||||
*
|
||||
* 3-column CSS grid: left side / center / right side.
|
||||
* - Center column holds: clients, ALB, Mattermost, data tier (so they all
|
||||
* align vertically — ALB is locked directly above Mattermost).
|
||||
* - Left column holds RTCD when Calls is on.
|
||||
* - Right column holds Push Proxy / HPNS.
|
||||
*
|
||||
* Connection labels are minimal (just ports). The Network Flows table
|
||||
* below the BOM documents what each port is for.
|
||||
*/
|
||||
|
||||
function ArchDiagram({
|
||||
tier,
|
||||
k8s,
|
||||
provider,
|
||||
showCalls,
|
||||
pushMode,
|
||||
}: {
|
||||
tier: Tier;
|
||||
k8s: boolean;
|
||||
provider: Provider;
|
||||
showCalls: boolean;
|
||||
pushMode: 'self-hosted' | 'hpns';
|
||||
}) {
|
||||
const showLb = tier.loadBalancer !== 'none';
|
||||
const showRedis = tier.redis;
|
||||
const showSearch = tier.search !== 'none';
|
||||
const showReplica = tier.dbReplicas > 0;
|
||||
|
||||
const lbName = k8s ? LB_BRAND_K8S[provider] : LB_BRAND_VM[provider];
|
||||
|
||||
return (
|
||||
<div className={styles.diagram} aria-label="Architecture diagram">
|
||||
{/* Client tier — labeled container, two cards (Web/Desktop + Mobile) */}
|
||||
<div className={styles.gridCenter}>
|
||||
<div className={styles.clientsBox}>
|
||||
<div className={styles.clientsLabel}>Clients</div>
|
||||
<div className={styles.clientsRow}>
|
||||
<div className={styles.client}>
|
||||
<div className={styles.clientLabel}>Web & Desktop</div>
|
||||
<div className={styles.clientSub}>Channels web app · Windows · macOS · Linux</div>
|
||||
</div>
|
||||
<div className={styles.client}>
|
||||
<div className={styles.clientLabel}>Mobile</div>
|
||||
<div className={styles.clientSub}>iOS · Android</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow row: main HTTPS arrow from clients into LB / Mattermost */}
|
||||
<div className={styles.gridCenter}>
|
||||
<FlowDown port="TCP/443" />
|
||||
</div>
|
||||
|
||||
{/* Edge tier (ALB) — center column, locked above Mattermost */}
|
||||
{showLb && (
|
||||
<>
|
||||
<div className={styles.gridCenter}>
|
||||
<div className={`${styles.box} ${styles.boxLb}`}>
|
||||
<div className={styles.boxLabel}>{lbName}</div>
|
||||
<div className={styles.boxSub}>
|
||||
{tier.loadBalancer === 'cluster' ? 'HA pair · TLS termination' : 'TLS termination'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.gridCenter}>
|
||||
<FlowDown port="TCP/8065" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* App row: RTCD (left) — Mattermost (center) — Push (right), all in one row.
|
||||
* The DIRECT pill floats absolutely above the RTCD box so it doesn't claim
|
||||
* its own grid row (which would create an empty band across the diagram). */}
|
||||
{showCalls && (
|
||||
<div className={styles.gridLeft}>
|
||||
<div className={styles.sideGroup}>
|
||||
<div className={styles.rtcdWrap}>
|
||||
<div className={styles.flowDirectAbove} aria-label="Direct client media path to RTCD">
|
||||
<span className={styles.directBadge}>direct</span>
|
||||
<span className={styles.flowArrowDiag}>↓</span>
|
||||
<span className={styles.flowPorts}>UDP+TCP/8443</span>
|
||||
</div>
|
||||
<div className={`${styles.box} ${styles.boxCalls}`}>
|
||||
<div className={styles.boxLabel}>RTCD (Calls)</div>
|
||||
<div className={styles.boxSub}>
|
||||
{tier.appNodes >= 8 ? 'HA (2–3 nodes)' : '1 node'} · audio + screen-share
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HFlow direction="left" port="TCP/8045" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.gridCenter}>
|
||||
<div className={`${styles.box} ${styles.boxApp}`}>
|
||||
<div className={styles.boxLabel}>
|
||||
Mattermost{k8s ? ' (Operator)' : ''}
|
||||
<span className={styles.qty}>×{tier.appNodes}</span>
|
||||
</div>
|
||||
<div className={styles.boxSub}>
|
||||
{tier.appCpu} vCPU · {tier.appRam} GB RAM{tier.ha ? ' · HA cluster' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridRight}>
|
||||
<div className={styles.sideGroup}>
|
||||
<HFlow direction="right" port="HTTPS/443" />
|
||||
{pushMode === 'self-hosted' ? (
|
||||
<div className={`${styles.box} ${styles.boxPush}`}>
|
||||
<div className={styles.boxLabel}>Push Proxy</div>
|
||||
<div className={styles.boxSub}>self-hosted</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.box} ${styles.boxPushManaged}`}>
|
||||
<div className={styles.boxLabel}>Mattermost HPNS</div>
|
||||
<div className={styles.boxSub}>
|
||||
<span className={styles.managedBadge}>managed by Mattermost</span>
|
||||
<br />
|
||||
Hosted Push Notifications Service
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data tier — spans full width below center */}
|
||||
<div className={`${styles.gridFull} ${styles.rowData}`}>
|
||||
<DataItem
|
||||
port="TCP/5432"
|
||||
accent={styles.boxDb}
|
||||
label={DB_BRAND[provider]}
|
||||
subtitle={`primary${showReplica ? ` + ${tier.dbReplicas} replica${tier.dbReplicas > 1 ? 's' : ''}` : ''}`}
|
||||
/>
|
||||
{showRedis && (
|
||||
<DataItem
|
||||
port="TCP/6379"
|
||||
accent={styles.boxRedis}
|
||||
label={REDIS_BRAND[provider]}
|
||||
subtitle={`${tier.appNodes >= 8 ? 'Cluster' : 'Single'} · sessions + cluster state`}
|
||||
/>
|
||||
)}
|
||||
{showSearch && (
|
||||
<DataItem
|
||||
port="HTTPS/9200"
|
||||
accent={styles.boxSearch}
|
||||
label={SEARCH_BRAND[provider]}
|
||||
subtitle={`${tier.searchNodes} node${tier.searchNodes > 1 ? 's' : ''} · message search`}
|
||||
/>
|
||||
)}
|
||||
<DataItem
|
||||
port="HTTPS/443"
|
||||
accent={styles.boxStorage}
|
||||
label={tier.storage === 'object' ? STORAGE_BRAND[provider] : 'Local disk'}
|
||||
subtitle={tier.storage === 'object' ? 'S3-compatible · uploads + plugins' : 'host disk (trial)'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================ Network Flows table ============================
|
||||
*
|
||||
* Documents every connection in the architecture: source → destination,
|
||||
* protocol, port, and purpose. Useful for firewall configuration.
|
||||
* Rows are conditional on the user's choices. */
|
||||
|
||||
type NetworkFlow = {
|
||||
from: string;
|
||||
to: string;
|
||||
protocol: string;
|
||||
port: string;
|
||||
direction: 'inbound' | 'internal' | 'egress';
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
function networkFlowsFor(
|
||||
tier: Tier,
|
||||
provider: Provider,
|
||||
showCalls: boolean,
|
||||
pushMode: 'self-hosted' | 'hpns',
|
||||
): NetworkFlow[] {
|
||||
const flows: NetworkFlow[] = [];
|
||||
|
||||
flows.push({
|
||||
from: 'Web / Desktop / Mobile clients',
|
||||
to: tier.loadBalancer !== 'none' ? 'Load balancer' : 'Mattermost',
|
||||
protocol: 'HTTPS + WSS',
|
||||
port: 'TCP/443',
|
||||
direction: 'inbound',
|
||||
purpose: 'Channels API, WebSocket events, file uploads',
|
||||
});
|
||||
|
||||
if (showCalls) {
|
||||
flows.push({
|
||||
from: 'Web / Desktop / Mobile clients',
|
||||
to: 'RTCD',
|
||||
protocol: 'UDP, TCP (fallback)',
|
||||
port: '8443',
|
||||
direction: 'inbound',
|
||||
purpose: 'Calls media (audio, video, screen-share) — direct, bypasses LB',
|
||||
});
|
||||
}
|
||||
|
||||
if (tier.loadBalancer !== 'none') {
|
||||
flows.push({
|
||||
from: 'Load balancer',
|
||||
to: 'Mattermost',
|
||||
protocol: 'HTTP',
|
||||
port: 'TCP/8065',
|
||||
direction: 'internal',
|
||||
purpose: 'TLS-terminated app traffic',
|
||||
});
|
||||
}
|
||||
|
||||
flows.push({
|
||||
from: 'Mattermost',
|
||||
to: `PostgreSQL (${DB_BRAND[provider]})`,
|
||||
protocol: 'TCP',
|
||||
port: '5432',
|
||||
direction: 'internal',
|
||||
purpose: 'Database queries',
|
||||
});
|
||||
|
||||
if (tier.redis) {
|
||||
flows.push({
|
||||
from: 'Mattermost',
|
||||
to: `Redis (${REDIS_BRAND[provider]})`,
|
||||
protocol: 'TCP',
|
||||
port: '6379',
|
||||
direction: 'internal',
|
||||
purpose: 'Sessions, cluster coordination, cache',
|
||||
});
|
||||
}
|
||||
|
||||
if (tier.search !== 'none') {
|
||||
flows.push({
|
||||
from: 'Mattermost',
|
||||
to: `OpenSearch (${SEARCH_BRAND[provider]})`,
|
||||
protocol: 'HTTPS',
|
||||
port: '9200',
|
||||
direction: 'internal',
|
||||
purpose: 'Message search index reads + writes',
|
||||
});
|
||||
}
|
||||
|
||||
flows.push({
|
||||
from: 'Mattermost',
|
||||
to: tier.storage === 'object' ? `Object storage (${STORAGE_BRAND[provider]})` : 'Local disk',
|
||||
protocol: 'HTTPS',
|
||||
port: 'TCP/443',
|
||||
direction: tier.storage === 'object' ? 'internal' : 'internal',
|
||||
purpose: 'File uploads, plugin assets, compliance exports',
|
||||
});
|
||||
|
||||
if (showCalls) {
|
||||
flows.push({
|
||||
from: 'Mattermost',
|
||||
to: 'RTCD',
|
||||
protocol: 'TCP',
|
||||
port: '8045',
|
||||
direction: 'internal',
|
||||
purpose: 'Calls signaling and orchestration API',
|
||||
});
|
||||
}
|
||||
|
||||
flows.push({
|
||||
from: 'Mattermost',
|
||||
to: pushMode === 'self-hosted' ? 'Mattermost Push Proxy (self-hosted)' : 'Mattermost HPNS (managed)',
|
||||
protocol: 'HTTPS',
|
||||
port: 'TCP/443',
|
||||
direction: pushMode === 'self-hosted' ? 'internal' : 'egress',
|
||||
purpose: 'Push notification submission',
|
||||
});
|
||||
|
||||
flows.push({
|
||||
from: pushMode === 'self-hosted' ? 'Mattermost Push Proxy' : 'Mattermost HPNS',
|
||||
to: 'Apple APNs (api.push.apple.com)',
|
||||
protocol: 'HTTPS',
|
||||
port: 'TCP/443',
|
||||
direction: 'egress',
|
||||
purpose: 'iOS push fan-out',
|
||||
});
|
||||
|
||||
flows.push({
|
||||
from: pushMode === 'self-hosted' ? 'Mattermost Push Proxy' : 'Mattermost HPNS',
|
||||
to: 'Google FCM (fcm.googleapis.com)',
|
||||
protocol: 'HTTPS',
|
||||
port: 'TCP/443',
|
||||
direction: 'egress',
|
||||
purpose: 'Android push fan-out',
|
||||
});
|
||||
|
||||
return flows;
|
||||
}
|
||||
|
||||
function NetworkFlowsTable({flows}: {flows: NetworkFlow[]}) {
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.bomHeading}>Network flows</h3>
|
||||
<p className={styles.flowsIntro}>
|
||||
Every connection in the architecture above — useful when configuring firewall
|
||||
rules, security groups, or a CAP. <strong>Inbound</strong> = from clients;{' '}
|
||||
<strong>internal</strong> = within the deployment boundary;{' '}
|
||||
<strong>egress</strong> = out to external services.
|
||||
</p>
|
||||
<div className={styles.bomTableWrap}>
|
||||
<table className={styles.bomTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Direction</th>
|
||||
<th>Protocol</th>
|
||||
<th>Port</th>
|
||||
<th>Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{flows.map((f, i) => (
|
||||
<tr key={`${f.from}-${f.to}-${i}`}>
|
||||
<td className={styles.cellComponent}>{f.from}</td>
|
||||
<td className={styles.cellComponent}>{f.to}</td>
|
||||
<td className={styles.cellQty}>
|
||||
<span className={`${styles.directionPill} ${styles[`dir_${f.direction}`]}`}>
|
||||
{f.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className={styles.cellSpec}>{f.protocol}</td>
|
||||
<td className={styles.cellSpec}>{f.port}</td>
|
||||
<td className={styles.cellNotes}>{f.purpose}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================ Main component ============================ */
|
||||
|
||||
export default function DeploymentArchitectureBuilder() {
|
||||
const [tierId, setTierId] = useState<string>('medium');
|
||||
const [deploymentType, setDeploymentType] = useState<DeploymentType>('vm');
|
||||
const [provider, setProvider] = useState<Provider>('aws');
|
||||
const [includeCalls, setIncludeCalls] = useState<boolean>(false);
|
||||
const [selfHostPush, setSelfHostPush] = useState<boolean>(true);
|
||||
|
||||
const tier = useMemo(
|
||||
() => TIERS.find((t) => t.id === tierId) ?? TIERS[0],
|
||||
[tierId],
|
||||
);
|
||||
|
||||
const bom = useMemo(
|
||||
() => bomFor(tier, deploymentType, provider, {calls: includeCalls, push: selfHostPush}),
|
||||
[tier, deploymentType, provider, includeCalls, selfHostPush],
|
||||
);
|
||||
|
||||
const flows = useMemo(
|
||||
() => networkFlowsFor(tier, provider, includeCalls, selfHostPush ? 'self-hosted' : 'hpns'),
|
||||
[tier, provider, includeCalls, selfHostPush],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={styles.builder} aria-label="Deployment architecture builder">
|
||||
<div className={styles.controls}>
|
||||
<ControlGroup label="Scale tier">
|
||||
<select
|
||||
className={styles.select}
|
||||
value={tierId}
|
||||
onChange={(e) => setTierId(e.target.value)}
|
||||
aria-label="Scale tier"
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.seats}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ControlGroup>
|
||||
|
||||
<ControlGroup label="Deployment type">
|
||||
<SegmentedControl
|
||||
name="deployment-type"
|
||||
value={deploymentType}
|
||||
onChange={(v) => setDeploymentType(v as DeploymentType)}
|
||||
options={[
|
||||
{value: 'vm', label: 'Virtual machines'},
|
||||
{value: 'kubernetes', label: 'Kubernetes'},
|
||||
]}
|
||||
/>
|
||||
</ControlGroup>
|
||||
|
||||
<ControlGroup label="Hosting">
|
||||
<select
|
||||
className={styles.select}
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value as Provider)}
|
||||
aria-label="Hosting"
|
||||
>
|
||||
{PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</ControlGroup>
|
||||
|
||||
<ControlGroup label="Optional services">
|
||||
<div className={styles.checkboxes}>
|
||||
<label className={styles.checkbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeCalls}
|
||||
onChange={(e) => setIncludeCalls(e.target.checked)}
|
||||
/>
|
||||
<span>Calls (RTCD)</span>
|
||||
</label>
|
||||
<label className={styles.checkbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selfHostPush}
|
||||
onChange={(e) => setSelfHostPush(e.target.checked)}
|
||||
/>
|
||||
<span>Self-host Push Proxy</span>
|
||||
</label>
|
||||
</div>
|
||||
</ControlGroup>
|
||||
</div>
|
||||
|
||||
<aside className={styles.summary} role="note">
|
||||
<strong>{tier.seats}</strong> — {tier.description}
|
||||
</aside>
|
||||
|
||||
<ArchDiagram
|
||||
tier={tier}
|
||||
k8s={deploymentType === 'kubernetes'}
|
||||
provider={provider}
|
||||
showCalls={includeCalls}
|
||||
pushMode={selfHostPush ? 'self-hosted' : 'hpns'}
|
||||
/>
|
||||
|
||||
<h3 className={styles.bomHeading}>Bill of materials</h3>
|
||||
<div className={styles.bomTableWrap}>
|
||||
<table className={styles.bomTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Quantity</th>
|
||||
<th>Recommended size / SKU</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bom.map((row, i) => (
|
||||
<tr key={`${row.component}-${i}`}>
|
||||
<td className={styles.cellComponent}>{row.component}</td>
|
||||
<td className={styles.cellQty}>{row.quantity}</td>
|
||||
<td className={styles.cellSpec}>{row.spec}</td>
|
||||
<td className={styles.cellNotes}>{row.notes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<NetworkFlowsTable flows={flows} />
|
||||
|
||||
<p className={styles.caveat}>
|
||||
Sizing follows the Mattermost scaling guide; provider SKUs are illustrative
|
||||
starting points. Verify against current pricing, regional availability, and any
|
||||
compliance constraints before procurement.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================ Small UI bits ============================ */
|
||||
|
||||
function ControlGroup({label, children}: {label: string; children: React.ReactNode}) {
|
||||
return (
|
||||
<div className={styles.controlGroup}>
|
||||
<div className={styles.controlLabel}>{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
name: string;
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: {value: T; label: string}[];
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.segmented} role="radiogroup" aria-label={name}>
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`${styles.segment} ${value === opt.value ? styles.segmentActive : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={opt.value}
|
||||
checked={value === opt.value}
|
||||
onChange={() => onChange(opt.value)}
|
||||
className={styles.segmentInput}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
/* Deployment Architecture builder.
|
||||
* Brand alignment: denim headers, marigold accents on boxes, white cards.
|
||||
* Layout: controls → summary → diagram (stacked rows) → bill of materials. */
|
||||
|
||||
.builder {
|
||||
margin: 1.5rem 0 2rem;
|
||||
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--mm-color-white);
|
||||
box-shadow: 0 4px 18px -14px rgba(30, 50, 92, 0.4);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .builder {
|
||||
background: var(--mm-denim-700, #121E37);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ============================ Controls ============================ */
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 0.9fr) minmax(220px, 1fr) minmax(280px, 1.3fr) minmax(180px, 0.9fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--mm-color-denim, #1E325C);
|
||||
color: var(--mm-color-white);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.controls { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.controls { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.controlGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.controlLabel {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: var(--mm-color-white);
|
||||
color: var(--mm-text-primary);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 4px;
|
||||
padding: 0.55rem 2.25rem 0.55rem 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--mm-font-sans);
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231E325C' stroke-width='2'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.55rem center;
|
||||
background-size: 1rem 1rem;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
padding: 0.2rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--mm-font-sans);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.segment:hover {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.segmentActive {
|
||||
background: var(--mm-color-marigold, #FFBC1F);
|
||||
color: var(--mm-color-black, #1B1D22) !important;
|
||||
}
|
||||
|
||||
.segmentInput {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Optional services checkboxes */
|
||||
.checkboxes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.55);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.checkbox input[type="checkbox"]:hover {
|
||||
border-color: var(--mm-color-marigold, #FFBC1F);
|
||||
}
|
||||
|
||||
.checkbox input[type="checkbox"]:checked {
|
||||
background: var(--mm-color-marigold, #FFBC1F);
|
||||
border-color: var(--mm-color-marigold, #FFBC1F);
|
||||
}
|
||||
|
||||
.checkbox input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid var(--mm-color-black, #1B1D22);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* ============================ Summary ============================ */
|
||||
|
||||
.summary {
|
||||
padding: 0.85rem 1.25rem;
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
|
||||
border-bottom: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.12));
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .summary {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.summary strong {
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .summary strong {
|
||||
color: var(--mm-color-marigold, #FFBC1F);
|
||||
}
|
||||
|
||||
/* ============================ Diagram ============================ */
|
||||
|
||||
/* Diagram laid out as a 3-column grid:
|
||||
* col 1 (1fr, justify-end): left-side components — RTCD when Calls is on
|
||||
* col 2 (auto): center spine — clients, ALB, Mattermost, data tier
|
||||
* col 3 (1fr, justify-start): right-side components — Push Proxy / HPNS
|
||||
*
|
||||
* The center column auto-sizes to Mattermost's box width, so the ALB sits
|
||||
* directly above Mattermost regardless of whether RTCD/Push are present.
|
||||
* Side columns fill remaining space and align toward the spine. */
|
||||
.diagram {
|
||||
display: grid;
|
||||
/* 1fr | auto | 1fr — equal side columns guarantee Mattermost is dead-centred
|
||||
* regardless of what's in the side columns (RTCD on/off, Push narrow/wide). */
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
row-gap: 0.85rem; /* generous vertical rhythm */
|
||||
column-gap: 1rem; /* visible breathing room around Mattermost-adjacent arrows */
|
||||
padding: 1.5rem 1.25rem 1rem;
|
||||
}
|
||||
|
||||
/* Center column — every spine element (clients, ALB, flows, Mattermost) lives here */
|
||||
.gridCenter {
|
||||
grid-column: 2;
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Left column — RTCD lives here when Calls is enabled */
|
||||
.gridLeft {
|
||||
grid-column: 1;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Right column — Push Proxy / HPNS lives here */
|
||||
.gridRight {
|
||||
grid-column: 3;
|
||||
justify-self: start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Full-width rows — data tier spans the whole diagram */
|
||||
.gridFull {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Side group: box + horizontal connector together (used in left/right columns).
|
||||
* Generous gap so the arrow has equal breathing room from its anchor box
|
||||
* and from Mattermost (matched to .diagram column-gap). */
|
||||
.sideGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Direct-flow pill sits in col 1, aligned to the right edge */
|
||||
.directFlowCell {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Legacy class kept for the few places using it (none active) */
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rowData {
|
||||
margin-top: 0.2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* App cluster — 3 columns aligned horizontally with arrow connectors:
|
||||
* [Push column] ← [Mattermost] → [RTCD column]
|
||||
* Stretches center-aligned; columns stack vertically inside (e.g., Push has
|
||||
* APNs/FCM below it). Collapses to vertical at narrow viewports. */
|
||||
.appCluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.3rem 0 0.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appCol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* Horizontal flow connector between columns — bordered pill with arrow + port */
|
||||
.hFlow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
font-family: var(--mm-font-mono, monospace);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
|
||||
border-bottom: 2px solid var(--mm-color-marigold, #FFBC1F);
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .hFlow {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.hArrow {
|
||||
color: var(--mm-color-marigold, #FFBC1F);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hFlowText {
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .hFlowText {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.hFlowPorts {
|
||||
margin-left: 0.45rem;
|
||||
font-weight: 500;
|
||||
color: var(--mm-text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .hFlowPorts {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
/* Each data tier component gets its own arrow + port indicator above it */
|
||||
.dataItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.miniFlow {
|
||||
font-family: var(--mm-font-mono, monospace);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
|
||||
border-left: 2px solid var(--mm-color-denim, #1E325C);
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .miniFlow {
|
||||
color: var(--mm-color-white);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-left-color: var(--mm-denim-300, #8499C5);
|
||||
}
|
||||
|
||||
.miniArrow {
|
||||
color: var(--mm-color-marigold, #FFBC1F);
|
||||
font-weight: 700;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
/* Flow label between rows — shows protocol + port */
|
||||
.flow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.15rem 0;
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--mm-font-mono, monospace);
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 2px;
|
||||
border-left: 2px solid var(--mm-color-denim, #1E325C);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .flow {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
border-left-color: var(--mm-denim-300, #8499C5);
|
||||
}
|
||||
|
||||
.flowArrow {
|
||||
color: var(--mm-color-marigold, #FFBC1F);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.flowText {
|
||||
font-weight: 600;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .flowText {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.flowPorts {
|
||||
margin-left: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--mm-text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .flowPorts {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
/* Client tier — wrapped in a labeled container "Clients" */
|
||||
.clientsBox {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 1.1rem 1rem 0.7rem;
|
||||
border: 1.5px dashed var(--mm-border-subtle, rgba(30, 50, 92, 0.35));
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .clientsBox {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.clientsLabel {
|
||||
position: absolute;
|
||||
top: -0.55rem;
|
||||
left: 0.85rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 800;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
background: var(--mm-color-white);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .clientsLabel {
|
||||
color: var(--mm-color-white);
|
||||
background: var(--mm-denim-700, #121E37);
|
||||
}
|
||||
|
||||
.clientsRow {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.client {
|
||||
flex: 0 0 auto;
|
||||
min-width: 12rem;
|
||||
max-width: 16rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.2));
|
||||
border-radius: 4px;
|
||||
background: var(--mm-color-white);
|
||||
color: var(--mm-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .client {
|
||||
color: var(--mm-color-white);
|
||||
background: var(--mm-denim-800, #0C1424);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.clientLabel {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .clientLabel {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.clientSub {
|
||||
font-size: 0.7rem;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Legacy class kept for backward compat — used by old .rowClients references */
|
||||
.rowClients {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.box {
|
||||
flex: 0 0 auto;
|
||||
min-width: 9rem;
|
||||
max-width: 14rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
|
||||
border-radius: 4px;
|
||||
background: var(--mm-color-white);
|
||||
color: var(--mm-text-primary);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .box {
|
||||
background: var(--mm-denim-800, #0C1424);
|
||||
color: var(--mm-color-white);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.boxLabel {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.2;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .boxLabel {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.qty {
|
||||
display: inline-block;
|
||||
margin-left: 0.3rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--mm-color-black);
|
||||
background: var(--mm-color-marigold, #FFBC1F);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.boxSub {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Per-role left-edge accents.
|
||||
* App-row boxes (App / Calls / Push) all share min-height so they align
|
||||
* horizontally in the grid row even when their subtitles wrap differently. */
|
||||
.boxLb { border-left: 3px solid var(--mm-color-marigold, #FFBC1F); min-width: 16rem; }
|
||||
.boxApp { border-left: 3px solid var(--mm-color-denim, #1E325C); min-width: 16rem; min-height: 4rem; }
|
||||
.boxDb { border-left: 3px solid #1d6f42; min-width: 11rem; } /* postgres green */
|
||||
.boxRedis { border-left: 3px solid #d4322c; min-width: 11rem; } /* redis red */
|
||||
.boxSearch { border-left: 3px solid #5a3aa6; min-width: 11rem; } /* opensearch purple */
|
||||
.boxStorage { border-left: 3px solid #4a5568; min-width: 11rem; } /* neutral */
|
||||
.boxCalls { border-left: 3px solid #007a5a; min-width: 12rem; min-height: 4rem; } /* calls teal-green */
|
||||
.boxPush { border-left: 3px solid #3a6ea5; min-width: 12rem; min-height: 4rem; } /* push blue (self-hosted) */
|
||||
|
||||
/* HPNS managed-by-Mattermost variant — dotted border + secondary background */
|
||||
.boxPushManaged {
|
||||
border: 1.5px dashed var(--mm-color-marigold, #FFBC1F);
|
||||
background: rgba(255, 188, 31, 0.06);
|
||||
min-width: 18rem;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .boxPushManaged {
|
||||
background: rgba(255, 188, 31, 0.1);
|
||||
}
|
||||
|
||||
.managedBadge {
|
||||
display: inline-block;
|
||||
margin-top: 0.2rem;
|
||||
padding: 0.08rem 0.45rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--mm-color-marigold, #FFBC1F);
|
||||
color: var(--mm-color-black, #1B1D22);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Flows-row container — sits between the client tier and the LB tier.
|
||||
* When Calls is enabled, the direct client→RTCD flow renders on the LEFT
|
||||
* (matching RTCD's column position below), and the main HTTPS+WSS path
|
||||
* renders to its right. */
|
||||
.flowsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.flowDirect {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
background: rgba(0, 122, 90, 0.08);
|
||||
border-left: 2px solid #007a5a;
|
||||
border-radius: 2px;
|
||||
font-family: var(--mm-font-mono, monospace);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .flowDirect {
|
||||
background: rgba(0, 122, 90, 0.18);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
/* RTCD column wrapper — relative anchor for the absolutely-positioned DIRECT pill */
|
||||
.rtcdWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Floating DIRECT pill: sits above the RTCD box without claiming a grid row */
|
||||
.flowDirectAbove {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.35rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
background: rgba(0, 122, 90, 0.08);
|
||||
border-left: 2px solid #007a5a;
|
||||
border-radius: 2px;
|
||||
font-family: var(--mm-font-mono, monospace);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .flowDirectAbove {
|
||||
background: rgba(0, 122, 90, 0.22);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.directBadge {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.35rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: #007a5a;
|
||||
color: var(--mm-color-white);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.flowArrowDiag {
|
||||
color: #007a5a;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.directText {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Legacy inline-in-RTCD callout (no longer used after layout swap) */
|
||||
.calloutDirect {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
background: rgba(0, 122, 90, 0.08);
|
||||
border-left: 2px solid #007a5a;
|
||||
border-radius: 2px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .calloutDirect {
|
||||
background: rgba(0, 122, 90, 0.18);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.calloutDirect code {
|
||||
font-family: var(--mm-font-mono, monospace);
|
||||
font-size: 0.66rem;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .calloutDirect code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.calloutBadge {
|
||||
display: inline-block;
|
||||
margin-right: 0.35rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: #007a5a;
|
||||
color: var(--mm-color-white);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* External services (APNs / FCM) — small, secondary visual treatment */
|
||||
.external {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.95rem;
|
||||
border: 1px dotted var(--mm-border-subtle, rgba(30, 50, 92, 0.3));
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .external {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.externalLabel {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.externalSub {
|
||||
font-size: 0.66rem;
|
||||
margin-top: 0.1rem;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ============================ Bill of materials ============================ */
|
||||
|
||||
.bomHeading {
|
||||
margin: 1.25rem 1.25rem 0.6rem;
|
||||
font-family: var(--mm-font-sans);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.bomHeading::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
background: var(--mm-color-marigold, #FFBC1F);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .bomHeading {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.bomTableWrap {
|
||||
overflow-x: auto;
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
|
||||
.bomTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.bomTable th,
|
||||
.bomTable td {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.1));
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.bomTable th {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
border-bottom: 2px solid var(--mm-color-denim, #1E325C);
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.04));
|
||||
}
|
||||
|
||||
[data-theme='dark'] .bomTable th {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.cellComponent { font-weight: 600; width: 18%; min-width: 9rem; }
|
||||
.cellQty { width: 12%; min-width: 5rem; color: var(--mm-text-secondary); }
|
||||
.cellSpec { width: 30%; font-family: var(--mm-font-mono, monospace); font-size: 0.8rem; }
|
||||
.cellNotes { width: 40%; color: var(--mm-text-secondary, var(--mm-text-primary)); opacity: 0.95; }
|
||||
|
||||
.caveat {
|
||||
margin: 0.9rem 1.25rem 1.25rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Network flows table — intro paragraph + direction pills */
|
||||
.flowsIntro {
|
||||
margin: 0.4rem 1.25rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
}
|
||||
|
||||
.directionPill {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 2px;
|
||||
color: var(--mm-color-white);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dir_inbound { background: #3a6ea5; }
|
||||
.dir_internal { background: #4a5568; }
|
||||
.dir_egress { background: #b45309; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.bomTable { min-width: 0; }
|
||||
.cellSpec, .cellNotes { font-size: 0.78rem; }
|
||||
}
|
||||
75
docs/site/src/components/DeploymentAvailability/index.tsx
Normal file
75
docs/site/src/components/DeploymentAvailability/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
// Per-page deployment-mode badge. Paired with <EditionAvailability/>.
|
||||
//
|
||||
// Canonical mode slugs:
|
||||
// cloud-shared — Mattermost Cloud (multi-tenant)
|
||||
// cloud-dedicated — Mattermost Cloud Dedicated (single-tenant managed)
|
||||
// cloud-government — Mattermost Cloud Government (FedRAMP Moderate)
|
||||
// self-hosted — customer-managed, internet-connected
|
||||
// air-gapped — customer-managed, network-isolated
|
||||
// tactical-edge — DDIL forward-deployed
|
||||
//
|
||||
// Usage:
|
||||
// <DeploymentAvailability modes="self-hosted,air-gapped,cloud-government" />
|
||||
|
||||
type Mode =
|
||||
| 'cloud-shared'
|
||||
| 'cloud-dedicated'
|
||||
| 'cloud-government'
|
||||
| 'self-hosted'
|
||||
| 'air-gapped'
|
||||
| 'tactical-edge';
|
||||
|
||||
const MODE_LABEL: Record<Mode, string> = {
|
||||
'cloud-shared': 'Cloud',
|
||||
'cloud-dedicated': 'Cloud Dedicated',
|
||||
'cloud-government': 'Cloud Government',
|
||||
'self-hosted': 'Self-Hosted',
|
||||
'air-gapped': 'Air-Gapped',
|
||||
'tactical-edge': 'Tactical Edge',
|
||||
};
|
||||
|
||||
const KNOWN_MODES = Object.keys(MODE_LABEL) as Mode[];
|
||||
|
||||
function parseModes(value: string): {valid: Mode[]; invalid: string[]} {
|
||||
const valid: Mode[] = [];
|
||||
const invalid: string[] = [];
|
||||
for (const raw of value.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
if ((KNOWN_MODES as string[]).includes(raw)) valid.push(raw as Mode);
|
||||
else invalid.push(raw);
|
||||
}
|
||||
return {valid, invalid};
|
||||
}
|
||||
|
||||
export default function DeploymentAvailability({modes}: {modes: string}) {
|
||||
const {valid, invalid} = parseModes(modes || '');
|
||||
|
||||
if (invalid.length > 0) {
|
||||
return (
|
||||
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
|
||||
Unknown deployment mode(s): <code>{invalid.join(', ')}</code>. Valid:{' '}
|
||||
<code>{KNOWN_MODES.join(', ')}</code>.
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
if (valid.length === 0) {
|
||||
return (
|
||||
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
|
||||
<code><DeploymentAvailability/></code> requires a non-empty <code>modes</code> prop.
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const labels = valid.map((m) => MODE_LABEL[m]).join(', ');
|
||||
return (
|
||||
<aside className={`${styles.badge} ${styles.deployment}`} role="note" aria-label="Deployment availability">
|
||||
<span className={styles.key}>Deployment</span>
|
||||
<span className={styles.value}>
|
||||
<Link href="/product-overview/editions-and-offerings">{labels}</Link>
|
||||
</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* Shares the visual contract with EditionAvailability. */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin: 0.35rem 0.6rem 0.35rem 0;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.2;
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
|
||||
border-left: 3px solid var(--mm-color-denim, #1E325C);
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badge {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.deployment {
|
||||
border-left-color: var(--mm-color-denim, #1E325C);
|
||||
}
|
||||
|
||||
.key {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted currentColor;
|
||||
}
|
||||
|
||||
.value a:hover {
|
||||
text-decoration: none;
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
.unknown {
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
76
docs/site/src/components/DeploymentOnly/index.tsx
Normal file
76
docs/site/src/components/DeploymentOnly/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import BrowserOnly from '@docusaurus/BrowserOnly';
|
||||
|
||||
// Inline conditional rendering keyed off the `?scope=` URL parameter.
|
||||
// Pattern adopted from Teleport (`?scope=enterprise`). Same canonical page,
|
||||
// multiple rendered variants by deployment mode.
|
||||
//
|
||||
// Usage:
|
||||
// <DeploymentOnly modes="self-hosted,air-gapped">
|
||||
// This paragraph only renders when ?scope=self-hosted or ?scope=air-gapped.
|
||||
// </DeploymentOnly>
|
||||
//
|
||||
// <DeploymentOnly modes="cloud-shared,cloud-dedicated,cloud-government" hideWhen="other">
|
||||
// Cloud-specific content. Hidden when ?scope= is set to a non-Cloud mode.
|
||||
// </DeploymentOnly>
|
||||
//
|
||||
// Behaviour:
|
||||
// - No `?scope=` in URL: render the content (default = show everything).
|
||||
// - `?scope=<mode>` present and `<mode>` is in `modes`: render content.
|
||||
// - `?scope=<mode>` present and NOT in `modes`: hide content.
|
||||
// - SSR: render content (matches "no scope" default — safe for SEO).
|
||||
|
||||
type Mode =
|
||||
| 'cloud-shared'
|
||||
| 'cloud-dedicated'
|
||||
| 'cloud-government'
|
||||
| 'self-hosted'
|
||||
| 'air-gapped'
|
||||
| 'tactical-edge';
|
||||
|
||||
const KNOWN_MODES: Mode[] = [
|
||||
'cloud-shared',
|
||||
'cloud-dedicated',
|
||||
'cloud-government',
|
||||
'self-hosted',
|
||||
'air-gapped',
|
||||
'tactical-edge',
|
||||
];
|
||||
|
||||
function getScope(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('scope');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function Inner({
|
||||
modes,
|
||||
children,
|
||||
}: {
|
||||
modes: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const allowed = (modes || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => (KNOWN_MODES as string[]).includes(s));
|
||||
|
||||
const scope = getScope();
|
||||
if (!scope) return <>{children}</>; // No filter set — show everything.
|
||||
if (allowed.includes(scope)) return <>{children}</>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function DeploymentOnly(props: {
|
||||
modes: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<BrowserOnly fallback={<>{props.children}</>}>
|
||||
{() => <Inner {...props} />}
|
||||
</BrowserOnly>
|
||||
);
|
||||
}
|
||||
71
docs/site/src/components/EditionAvailability/index.tsx
Normal file
71
docs/site/src/components/EditionAvailability/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
// Per-page edition badge. Authors mark which Mattermost editions a feature
|
||||
// or configuration applies to. Renders a single-row inline badge at the
|
||||
// top of the page (paired with <DeploymentAvailability/>).
|
||||
//
|
||||
// Canonical tier slugs:
|
||||
// free — Mattermost Team Edition (free, OSS)
|
||||
// professional — Mattermost Professional
|
||||
// enterprise — Mattermost Enterprise
|
||||
// enterprise-advanced — Mattermost Enterprise Advanced
|
||||
//
|
||||
// Usage:
|
||||
// <EditionAvailability tiers="enterprise,enterprise-advanced" />
|
||||
// <EditionAvailability tiers="free,professional,enterprise,enterprise-advanced" />
|
||||
|
||||
type Tier = 'free' | 'professional' | 'enterprise' | 'enterprise-advanced';
|
||||
|
||||
const TIER_LABEL: Record<Tier, string> = {
|
||||
'free': 'Team Edition',
|
||||
'professional': 'Professional',
|
||||
'enterprise': 'Enterprise',
|
||||
'enterprise-advanced': 'Enterprise Advanced',
|
||||
};
|
||||
|
||||
const KNOWN_TIERS = Object.keys(TIER_LABEL) as Tier[];
|
||||
|
||||
function parseTiers(value: string): {valid: Tier[]; invalid: string[]} {
|
||||
const valid: Tier[] = [];
|
||||
const invalid: string[] = [];
|
||||
for (const raw of value.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
if ((KNOWN_TIERS as string[]).includes(raw)) {
|
||||
valid.push(raw as Tier);
|
||||
} else {
|
||||
invalid.push(raw);
|
||||
}
|
||||
}
|
||||
return {valid, invalid};
|
||||
}
|
||||
|
||||
export default function EditionAvailability({tiers}: {tiers: string}) {
|
||||
const {valid, invalid} = parseTiers(tiers || '');
|
||||
|
||||
if (invalid.length > 0) {
|
||||
return (
|
||||
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
|
||||
Unknown edition tier(s): <code>{invalid.join(', ')}</code>. Valid:{' '}
|
||||
<code>{KNOWN_TIERS.join(', ')}</code>.
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
if (valid.length === 0) {
|
||||
return (
|
||||
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
|
||||
<code><EditionAvailability/></code> requires a non-empty <code>tiers</code> prop.
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const labels = valid.map((t) => TIER_LABEL[t]).join(', ');
|
||||
return (
|
||||
<aside className={`${styles.badge} ${styles.edition}`} role="note" aria-label="Edition availability">
|
||||
<span className={styles.key}>Edition</span>
|
||||
<span className={styles.value}>
|
||||
<Link href="/product-overview/editions-and-offerings">{labels}</Link>
|
||||
</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Per-page Edition / Deployment / Attestation badges.
|
||||
* Shared visual contract; the three badge components compose into a
|
||||
* single inline row at the top of the page. */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin: 0.35rem 0.6rem 0.35rem 0;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.2;
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
|
||||
border-left: 3px solid var(--mm-color-denim, #1E325C);
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badge {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Edition and Deployment badges share the same denim left-edge — no color
|
||||
* differentiation between them; the label text already distinguishes them. */
|
||||
.edition {
|
||||
border-left-color: var(--mm-color-denim, #1E325C);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .edition {
|
||||
border-left-color: var(--mm-denim-300, #8499C5);
|
||||
}
|
||||
|
||||
.key {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted currentColor;
|
||||
}
|
||||
|
||||
.value a:hover {
|
||||
text-decoration: none;
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
.unknown {
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
17
docs/site/src/components/Eyebrow/index.tsx
Normal file
17
docs/site/src/components/Eyebrow/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
/**
|
||||
* Small section-name marker that sits above the H1 on inner-page
|
||||
* landings. Replaces the Hero block on /api, /api/examples, and
|
||||
* /developers — content-first, no marketing flair, brand presence
|
||||
* via the marigold uppercase eyebrow only.
|
||||
*
|
||||
* Usage:
|
||||
* <Eyebrow>API Reference</Eyebrow>
|
||||
* # REST API v4
|
||||
* Subtitle paragraph...
|
||||
*/
|
||||
export default function Eyebrow({children}: {children: React.ReactNode}) {
|
||||
return <div className={styles.eyebrow}>{children}</div>;
|
||||
}
|
||||
16
docs/site/src/components/Eyebrow/styles.module.css
Normal file
16
docs/site/src/components/Eyebrow/styles.module.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
font-family: var(--mm-font-heading);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mm-marigold-700);
|
||||
border-bottom: 2px solid var(--mm-color-marigold);
|
||||
padding-bottom: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .eyebrow {
|
||||
color: var(--mm-color-marigold);
|
||||
}
|
||||
69
docs/site/src/components/Hero/index.tsx
Normal file
69
docs/site/src/components/Hero/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type Cta = {
|
||||
label: string;
|
||||
to: string;
|
||||
variant?: 'primary' | 'ghost';
|
||||
};
|
||||
|
||||
type Props = {
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
ctas?: Cta[];
|
||||
/** Pillars under the hero — defaults to brand pillars on the home hero. */
|
||||
pillars?: {label: string; description?: string}[];
|
||||
/** Restraint level: 'full' for /, 'compact' for /developers /api. */
|
||||
intensity?: 'full' | 'compact';
|
||||
};
|
||||
|
||||
export default function Hero({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
ctas = [],
|
||||
pillars,
|
||||
intensity = 'compact',
|
||||
}: Props): React.ReactElement {
|
||||
const camoUrl = useBaseUrl('/img/patterns/hex-camo-denim.svg');
|
||||
return (
|
||||
<section
|
||||
className={`mm-hero ${styles.hero} ${intensity === 'full' ? styles.full : styles.compact}`}
|
||||
style={{['--mm-hero-camo' as string]: `url(${camoUrl})`}}
|
||||
>
|
||||
<div className={styles.inner}>
|
||||
{eyebrow && <div className={styles.eyebrow}>{eyebrow}</div>}
|
||||
{title && <h1 className={styles.title}>{title}</h1>}
|
||||
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
||||
{ctas.length > 0 && (
|
||||
<div className={styles.ctas}>
|
||||
{ctas.map((cta, i) => (
|
||||
<Link
|
||||
key={`${cta.label}-${i}`}
|
||||
to={cta.to}
|
||||
className={cta.variant === 'ghost' ? styles.ctaGhost : styles.ctaPrimary}
|
||||
>
|
||||
{cta.label}
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pillars && (
|
||||
<ul className={styles.pillars}>
|
||||
{pillars.map((p) => (
|
||||
<li key={p.label}>
|
||||
<strong>{p.label}</strong>
|
||||
{p.description && <span>{p.description}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.curve} aria-hidden />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
175
docs/site/src/components/Hero/styles.module.css
Normal file
175
docs/site/src/components/Hero/styles.module.css
Normal file
@@ -0,0 +1,175 @@
|
||||
/* Branded landing-page hero. Mid-intensity per design call:
|
||||
* full-bleed denim with hex camo, but capped height so it doesn't
|
||||
* dominate every visit. The curved bottom edge (`.curve`) matches
|
||||
* the brand book's photo/color transition treatment.
|
||||
*/
|
||||
|
||||
/* The hero fills the article column it lives in (no full-viewport escape).
|
||||
* Why: 100vw includes scrollbar width on Windows/Chrome and produces a
|
||||
* horizontal-scroll bleed. Also when a sidebar is present, escaping to
|
||||
* 100vw misaligns the hero's centered inner content vs. the body content
|
||||
* below (which sits in the article column). Containing the hero keeps
|
||||
* its left/right edges consistent with the rest of the page. */
|
||||
/* The hero is a flat banded section, no rounded corners (the card-like
|
||||
* frame felt clunky and disconnected from the rest of the page). The
|
||||
* pattern sits under a heavier dimming overlay so it reads as
|
||||
* atmospheric texture, not foreground motif. */
|
||||
.hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-top: -1.25rem;
|
||||
margin-bottom: 2.5rem;
|
||||
background-color: var(--mm-color-denim);
|
||||
background-image:
|
||||
linear-gradient(180deg, rgba(12, 20, 36, 0.55) 0%, rgba(12, 20, 36, 0.25) 100%),
|
||||
radial-gradient(ellipse 50% 100% at 100% 50%, rgba(91, 119, 177, 0.15) 0%, rgba(30, 50, 92, 0) 60%),
|
||||
var(--mm-hero-camo);
|
||||
background-size: cover, cover, 108px 125px;
|
||||
background-repeat: no-repeat, no-repeat, repeat;
|
||||
color: var(--mm-color-white);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
border-bottom: 3px solid var(--mm-color-marigold);
|
||||
}
|
||||
|
||||
.full {
|
||||
padding: clamp(2rem, 3.5vw, 3rem) 0 clamp(2rem, 3.5vw, 3rem);
|
||||
}
|
||||
|
||||
.compact {
|
||||
padding: clamp(1.5rem, 2.5vw, 2.25rem) 0 clamp(1.75rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.inner {
|
||||
/* Fill the hero band; the band itself is sized by its parent
|
||||
* (the article column). Padding keeps content off the band edges. */
|
||||
margin: 0 auto;
|
||||
padding: 0 clamp(1.25rem, 3vw, 2rem);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
font-family: var(--mm-font-heading);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mm-color-marigold);
|
||||
border: 1px solid rgba(255, 188, 31, 0.55);
|
||||
padding: 0.32rem 0.75rem;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--mm-font-heading);
|
||||
font-weight: 900;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.4rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mm-color-white);
|
||||
margin: 0 0 0.75rem;
|
||||
max-width: 28ch;
|
||||
}
|
||||
|
||||
.full .title {
|
||||
font-size: clamp(1.85rem, 3.8vw, 3rem);
|
||||
max-width: 24ch;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: clamp(0.95rem, 1.2vw, 1.1rem);
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
max-width: 64ch;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ctaPrimary,
|
||||
.ctaGhost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1.1rem;
|
||||
font-family: var(--mm-font-heading);
|
||||
font-weight: 900;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.ctaPrimary {
|
||||
background: var(--mm-color-marigold);
|
||||
color: var(--mm-color-black);
|
||||
border: 2px solid var(--mm-color-marigold);
|
||||
}
|
||||
.ctaPrimary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 18px -10px rgba(0, 0, 0, 0.5);
|
||||
color: var(--mm-color-black);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ctaGhost {
|
||||
background: transparent;
|
||||
color: var(--mm-color-white);
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.ctaGhost:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--mm-color-white);
|
||||
color: var(--mm-color-white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pillars {
|
||||
list-style: none;
|
||||
padding: 1.75rem 0 0;
|
||||
margin: 1.75rem 0 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.pillars { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
.pillars li {
|
||||
position: relative;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 2px solid var(--mm-color-marigold);
|
||||
}
|
||||
|
||||
.pillars strong {
|
||||
display: block;
|
||||
font-family: var(--mm-font-heading);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.pillars span {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.curve { display: none; } /* dropping the curve — too theatrical at this scale */
|
||||
254
docs/site/src/components/IMEDiagram/index.tsx
Normal file
254
docs/site/src/components/IMEDiagram/index.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
/**
|
||||
* Interactive Intelligent Mission Environment (IME) diagram.
|
||||
*
|
||||
* Recreates the four-layer IME infographic (Use Cases → Application →
|
||||
* Integration → Deployment) with every region linked to its canonical
|
||||
* documentation page. Replaces the static infographic image on the
|
||||
* landing page so visitors can drill into the docs from the diagram
|
||||
* itself.
|
||||
*/
|
||||
|
||||
type CellProps = {
|
||||
to: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Cell({to, title, children, icon}: CellProps) {
|
||||
return (
|
||||
<Link to={to} className={styles.cell}>
|
||||
{icon && <span className={styles.icon} aria-hidden>{icon}</span>}
|
||||
<div className={styles.cellBody}>
|
||||
<strong className={styles.cellTitle}>{title}</strong>
|
||||
<div className={styles.cellText}>{children}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* === Inline brand-aligned SVG icons (marigold tone via currentColor) === */
|
||||
const IconChat = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||||
</svg>
|
||||
);
|
||||
const IconChecklist = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||
</svg>
|
||||
);
|
||||
const IconCall = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.37 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.33 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>
|
||||
</svg>
|
||||
);
|
||||
const IconTarget = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="6"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
</svg>
|
||||
);
|
||||
const IconSparkles = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/>
|
||||
</svg>
|
||||
);
|
||||
const IconShield = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
);
|
||||
const IconCode = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
);
|
||||
const IconServer = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="3" width="20" height="6" rx="1"/>
|
||||
<rect x="2" y="15" width="20" height="6" rx="1"/>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||
</svg>
|
||||
);
|
||||
const IconCompass = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>
|
||||
</svg>
|
||||
);
|
||||
const IconLayers = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
);
|
||||
const IconPlug = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
);
|
||||
const IconGlobe = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
);
|
||||
const IconDevices = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="3" width="14" height="11" rx="1"/>
|
||||
<rect x="14" y="9" width="8" height="11" rx="1"/>
|
||||
<line x1="2" y1="17" x2="11" y2="17"/>
|
||||
</svg>
|
||||
);
|
||||
const IconVideo = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="23 7 16 12 23 17 23 7"/>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function IMEDiagram() {
|
||||
return (
|
||||
<section className={styles.diagram} aria-label="Intelligent Mission Environment overview">
|
||||
{/* USE CASES */}
|
||||
<div className={`${styles.layer} ${styles.layerUseCases}`}>
|
||||
<div className={styles.layerLabel}>Use Cases</div>
|
||||
<div className={styles.layerBody}>
|
||||
<div className={styles.useCaseRow}>
|
||||
<Cell to="/use-case-guide/integrated-security-operations" title="Cyber Defense" icon={<IconShield />}>
|
||||
SOC/CERT ops, out-of-band incident response, Red Team
|
||||
</Cell>
|
||||
<Cell to="/use-case-guide/devops-collaboration" title="DevSecOps" icon={<IconCode />}>
|
||||
Dev productivity, CI/CD, platform engineering, emergency comms
|
||||
</Cell>
|
||||
<Cell to="/use-case-guide/secure-command-and-control" title="Mission Operations" icon={<IconCompass />}>
|
||||
Critical workflow, Zero Trust, C2 to tactical edge, joint ops
|
||||
</Cell>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* APPLICATION */}
|
||||
<div className={`${styles.layer} ${styles.layerApplication}`}>
|
||||
<div className={styles.layerLabel}>Application</div>
|
||||
<div className={styles.layerBody}>
|
||||
<div className={styles.appGrid}>
|
||||
<div className={styles.appIntro}>
|
||||
<h3><span className={styles.introIcon}><IconLayers /></span>Secure Collaborative Workflow</h3>
|
||||
<p>
|
||||
Messaging collaboration across web, desktop, and mobile with file sharing,
|
||||
audio/screenshare, workflow automation, issue tracking, bots, agents, and open
|
||||
API access — integrating into modern toolchains and legacy systems with
|
||||
advanced customization and security controls.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.productGrid}>
|
||||
<Cell to="/end-user-guide/collaborate/collaborate-index" title="Messaging Collaboration" icon={<IconChat />}>
|
||||
Mattermost Channels for ChatOps and automation
|
||||
</Cell>
|
||||
<Cell to="/end-user-guide/workflow-automation/workflow-automation-index" title="Workflow Automation" icon={<IconChecklist />}>
|
||||
Mattermost Playbooks for automating SOPs
|
||||
</Cell>
|
||||
<Cell to="/end-user-guide/collaborate/audio-and-screensharing" title="Audio & Screenshare" icon={<IconCall />}>
|
||||
Mattermost Calls for real‑time calling and screenshare
|
||||
</Cell>
|
||||
<Cell to="/end-user-guide/project-management/project-management-index" title="Project Tracking" icon={<IconTarget />}>
|
||||
Mattermost Boards for Kanban and work management
|
||||
</Cell>
|
||||
<Cell to="/end-user-guide/agents" title="AI Agents & Open APIs" icon={<IconSparkles />}>
|
||||
Mattermost Agents — AI assistance and integration
|
||||
</Cell>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.appFooter}>
|
||||
<Link to="/end-user-guide/access/access-your-workspace" className={styles.footerCell}>
|
||||
<span className={styles.footerIcon}><IconDevices /></span>
|
||||
Desktop, web, mobile & Microsoft Teams clients
|
||||
</Link>
|
||||
<Link to="/developers" className={styles.footerCell}>
|
||||
<span className={styles.footerIcon}><IconCode /></span>
|
||||
Extensible open-source architecture
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* INTEGRATION */}
|
||||
<div className={`${styles.layer} ${styles.layerIntegration}`}>
|
||||
<div className={styles.layerLabel}>Integration</div>
|
||||
<div className={styles.layerBody}>
|
||||
<div className={styles.intGrid}>
|
||||
<div className={styles.intIntro}>
|
||||
<h3><span className={styles.introIcon}><IconPlug /></span>Integration & AI Platform</h3>
|
||||
<p>
|
||||
Operational extensibility with pre-packaged, source-available connectors,
|
||||
automations, and templates for rapid and effective systems integration.
|
||||
</p>
|
||||
</div>
|
||||
<Cell to="/integrations-guide/integrations-guide-index" title="Layered Extensibility" icon={<IconCode />}>
|
||||
<ul>
|
||||
<li>Pre-packaged and custom integrations</li>
|
||||
<li>Webhooks and slash commands</li>
|
||||
<li>Plugin architecture</li>
|
||||
</ul>
|
||||
</Cell>
|
||||
<Cell to="/end-user-guide/agents" title="Multi-Agent / Multi-LLM Integration" icon={<IconSparkles />}>
|
||||
<ul>
|
||||
<li>Sovereign AI model support via OpenAI APIs</li>
|
||||
<li>Custom instructions, RAG, semantic search</li>
|
||||
<li>Responsible AI control plane</li>
|
||||
<li>MCP and agent-to-agent architecture</li>
|
||||
</ul>
|
||||
</Cell>
|
||||
</div>
|
||||
<div className={styles.appFooter}>
|
||||
<Link to="/integrations-guide/integrations-guide-index" className={styles.footerCell}>
|
||||
<span className={styles.footerIcon}><IconVideo /></span>
|
||||
Video meetings: Pexip · Webex · Cisco
|
||||
</Link>
|
||||
<Link to="/integrations-guide/integrations-guide-index" className={styles.footerCell}>
|
||||
<span className={styles.footerIcon}><IconPlug /></span>
|
||||
Pre-built: GitHub · GitLab · Jira · ServiceNow · M365
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DEPLOYMENT */}
|
||||
<div className={`${styles.layer} ${styles.layerDeployment}`}>
|
||||
<div className={styles.layerLabel}>Deployment</div>
|
||||
<div className={styles.layerBody}>
|
||||
<div className={styles.depGrid}>
|
||||
<div className={styles.depIntro}>
|
||||
<h3><span className={styles.introIcon}><IconGlobe /></span>Sovereign, Cyber‑Resilient Deployment</h3>
|
||||
<p>
|
||||
Kubernetes-based orchestration on private, government, and air-gapped clouds.
|
||||
Scales from tactical edge to strategic core with geo-distributed ultra-high
|
||||
availability.
|
||||
</p>
|
||||
</div>
|
||||
<Cell to="/deployment-guide/deployment-scenarios/deployment-scenarios-index" title="Tactical Edge to Strategic Core" icon={<IconServer />}>
|
||||
Runs at the edge, in your data center, in sovereign clouds, and on global hyperscalers: Azure, AWS, Google Cloud, Oracle Cloud.
|
||||
</Cell>
|
||||
<Cell to="/for/air-gapped-operator" title="Mission-Ready Security & Resilience" icon={<IconShield />}>
|
||||
<ul>
|
||||
<li>Classified, air-gapped, and DDIL operations</li>
|
||||
<li>Defense-grade controls, monitoring, and mobile security</li>
|
||||
<li>Scales to 200K+ users</li>
|
||||
</ul>
|
||||
</Cell>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
354
docs/site/src/components/IMEDiagram/styles.module.css
Normal file
354
docs/site/src/components/IMEDiagram/styles.module.css
Normal file
@@ -0,0 +1,354 @@
|
||||
/* Intelligent Mission Environment diagram — v2.
|
||||
*
|
||||
* Tighter visual match to the brand infographic:
|
||||
* - Application zone visibly contained in a denim wash + border
|
||||
* - Intro panels are NOT clickable cards; they're plain text columns
|
||||
* (no left-edge accent bar — the surrounding denim wash/borders already
|
||||
* signal the section boundary)
|
||||
* - Use Case cells use a soft denim-tinted background, not white-on-white
|
||||
* - All clickable cells share the same white-with-marigold-on-hover treatment
|
||||
* - Section titles get a thin marigold underline for visual rhythm
|
||||
*/
|
||||
|
||||
.diagram {
|
||||
margin: 2rem 0 2.5rem;
|
||||
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--mm-color-white);
|
||||
box-shadow: 0 4px 18px -14px rgba(30, 50, 92, 0.4);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .diagram {
|
||||
background: var(--mm-denim-700, #121E37);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* === Layer scaffold (vertical denim label on left + content area on right) === */
|
||||
|
||||
.layer {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr;
|
||||
border-top: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.15));
|
||||
}
|
||||
.layer:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.layerLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--mm-color-denim, #1E325C);
|
||||
color: var(--mm-color-white, #fff);
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 800;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
padding: 0.85rem 0;
|
||||
}
|
||||
|
||||
.layerBody {
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--mm-bg-page, #fff);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .layerBody {
|
||||
background: var(--mm-denim-800, #0C1424);
|
||||
}
|
||||
|
||||
/* Application zone gets a stronger denim wash to visibly contain it,
|
||||
matching the original infographic where the whole Application block
|
||||
reads as one tinted panel. */
|
||||
.layerApplication .layerBody {
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
|
||||
}
|
||||
[data-theme='dark'] .layerApplication .layerBody {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* === Use Cases row — 3 horizontal cells, soft grey-tinted === */
|
||||
|
||||
.useCaseRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.useCaseRow .cell {
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.045));
|
||||
border-color: transparent;
|
||||
}
|
||||
.useCaseRow .cell:hover {
|
||||
background: var(--mm-color-white, #fff);
|
||||
border-color: var(--mm-color-marigold, #FFBC1F);
|
||||
}
|
||||
[data-theme='dark'] .useCaseRow .cell {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* === Intro panels (Application / Integration / Deployment left column) ===
|
||||
Not clickable — plain text columns, no left-edge accent bar. */
|
||||
|
||||
.appIntro,
|
||||
.intIntro,
|
||||
.depIntro {
|
||||
padding: 0.25rem 0.6rem 0.25rem 0;
|
||||
}
|
||||
|
||||
.appIntro h3,
|
||||
.intIntro h3,
|
||||
.depIntro h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-family: var(--mm-font-sans);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
margin: 0 0 0.4rem;
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.introIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
}
|
||||
|
||||
.introIcon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .appIntro .introIcon,
|
||||
[data-theme='dark'] .intIntro .introIcon,
|
||||
[data-theme='dark'] .depIntro .introIcon {
|
||||
color: var(--mm-denim-300, #8499C5);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .appIntro h3,
|
||||
[data-theme='dark'] .intIntro h3,
|
||||
[data-theme='dark'] .depIntro h3 {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.appIntro p,
|
||||
.intIntro p,
|
||||
.depIntro p {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* === Application grid: intro + 5 product cards === */
|
||||
|
||||
.appGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.1fr) 3.4fr;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.productGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.appFooter {
|
||||
display: grid;
|
||||
/* Footer strips read as standalone bands — balanced columns so neither
|
||||
* label wraps mid-phrase. (The grid above intentionally mirrors the
|
||||
* intro/products asymmetry; the footer doesn't need to.) */
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.footerCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.15));
|
||||
border-radius: 4px;
|
||||
background: var(--mm-bg-page, #fff);
|
||||
color: var(--mm-text-primary);
|
||||
text-decoration: none;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.footerIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
color: var(--mm-color-marigold, #FFBC1F);
|
||||
}
|
||||
|
||||
.footerIcon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.footerCell:hover {
|
||||
border-color: var(--mm-color-marigold, #FFBC1F);
|
||||
background: var(--mm-marigold-100, rgba(255, 188, 31, 0.08));
|
||||
color: var(--mm-text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .footerCell {
|
||||
background: var(--mm-denim-700, #121E37);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
/* === Integration & Deployment grids === */
|
||||
|
||||
.intGrid,
|
||||
.depGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.1fr) 1.7fr 1.7fr;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* === Cell — the reusable clickable unit === */
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
|
||||
border-radius: 4px;
|
||||
background: var(--mm-bg-page, #fff);
|
||||
color: var(--mm-text-primary);
|
||||
text-decoration: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
.cell:hover {
|
||||
border-color: var(--mm-color-marigold, #FFBC1F);
|
||||
box-shadow: 0 4px 14px -10px rgba(30, 50, 92, 0.35);
|
||||
text-decoration: none;
|
||||
color: var(--mm-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .cell {
|
||||
background: var(--mm-denim-700, #121E37);
|
||||
color: var(--mm-color-white);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
[data-theme='dark'] .cell:hover {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
color: var(--mm-color-marigold, #FFBC1F);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cellBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.cellTitle {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.25;
|
||||
color: var(--mm-color-denim, #1E325C);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .cellTitle {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.cellText {
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.4;
|
||||
color: var(--mm-text-secondary, var(--mm-text-primary));
|
||||
}
|
||||
|
||||
.cellText ul {
|
||||
margin: 0.2rem 0 0;
|
||||
padding-left: 0.95rem;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.cellText li {
|
||||
margin: 0.08rem 0;
|
||||
}
|
||||
|
||||
/* === Use Case cells get larger title + tighter sub-text — readable at row scale === */
|
||||
|
||||
.useCaseRow .cellTitle {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.useCaseRow .cellText {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* === Responsive collapse === */
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.appGrid,
|
||||
.intGrid,
|
||||
.depGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.productGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.appFooter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.useCaseRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.productGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.layer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.layerLabel {
|
||||
writing-mode: horizontal-tb;
|
||||
transform: none;
|
||||
padding: 0.4rem 0.85rem;
|
||||
justify-content: flex-start;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
29
docs/site/src/components/MethodLegend/index.tsx
Normal file
29
docs/site/src/components/MethodLegend/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const METHODS: Array<{verb: string; tone: string; meaning: string}> = [
|
||||
{verb: 'GET', tone: 'get', meaning: 'Read'},
|
||||
{verb: 'POST', tone: 'post', meaning: 'Create / action'},
|
||||
{verb: 'PUT', tone: 'put', meaning: 'Replace'},
|
||||
{verb: 'PATCH', tone: 'patch', meaning: 'Update fields'},
|
||||
{verb: 'DELETE', tone: 'delete', meaning: 'Remove'},
|
||||
];
|
||||
|
||||
/**
|
||||
* Inline legend that decodes the HTTP method colors used throughout
|
||||
* the API sidebar and endpoint pages. Most readers internalize these
|
||||
* fast — but the first 30 seconds on the API landing matter, and the
|
||||
* legend collapses that ramp.
|
||||
*/
|
||||
export default function MethodLegend() {
|
||||
return (
|
||||
<ul className={styles.legend}>
|
||||
{METHODS.map((m) => (
|
||||
<li key={m.verb} className={styles.item}>
|
||||
<span className={`${styles.badge} ${styles[m.tone]}`}>{m.verb}</span>
|
||||
<span className={styles.meaning}>{m.meaning}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
41
docs/site/src/components/MethodLegend/styles.module.css
Normal file
41
docs/site/src/components/MethodLegend/styles.module.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.legend {
|
||||
list-style: none;
|
||||
margin: 1rem 0 2rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 3.6rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-family: var(--mm-font-mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 3px;
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.get { background: #2D7BD2; } /* operational blue */
|
||||
.post { background: #2C7A4F; } /* operational green */
|
||||
.put { background: #A86A1F; } /* operational amber */
|
||||
.patch { background: #6B4FA0; } /* operational violet */
|
||||
.delete { background: #8B1F1F; } /* operational red */
|
||||
|
||||
.meaning {
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
124
docs/site/src/components/PlanAvailability/index.tsx
Normal file
124
docs/site/src/components/PlanAvailability/index.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
// Plan-availability and Academy badges. Replaces Sphinx's
|
||||
// .. include:: /_static/badges/<slug>.rst
|
||||
// pattern. Slug values mirror the original badge filenames so the
|
||||
// mechanical RST→MDX conversion is a 1:1 rewrite.
|
||||
|
||||
type PlanData = {
|
||||
kind: 'plan';
|
||||
text: string;
|
||||
linkText: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type AcademyData = {
|
||||
kind: 'academy';
|
||||
title: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const BADGES: Record<string, PlanData | AcademyData> = {
|
||||
// Plan-availability banners
|
||||
'all-commercial': {
|
||||
kind: 'plan',
|
||||
text: 'Available on',
|
||||
linkText: 'Entry, Professional, Enterprise, and Enterprise Advanced plans',
|
||||
href: 'https://mattermost.com/pricing/',
|
||||
},
|
||||
'ent-plus': {
|
||||
kind: 'plan',
|
||||
text: 'Available on',
|
||||
linkText: 'Enterprise and Enterprise Advanced plans',
|
||||
href: 'https://mattermost.com/pricing/',
|
||||
},
|
||||
'ent-adv': {
|
||||
kind: 'plan',
|
||||
text: 'Available on',
|
||||
linkText: 'Enterprise Advanced plans',
|
||||
href: 'https://mattermost.com/pricing/',
|
||||
},
|
||||
'ent-cloud-dedicated': {
|
||||
kind: 'plan',
|
||||
text: 'Available only on',
|
||||
linkText: 'Enterprise and Enterprise Advanced plans',
|
||||
href: 'https://mattermost.com/contact-sales/',
|
||||
},
|
||||
'entry-adv': {
|
||||
kind: 'plan',
|
||||
text: 'Available on',
|
||||
linkText: 'Entry and Enterprise Advanced plans',
|
||||
href: 'https://mattermost.com/pricing/',
|
||||
},
|
||||
'entry-ent': {
|
||||
kind: 'plan',
|
||||
text: 'Available on',
|
||||
linkText: 'Entry, Enterprise, and Enterprise Advanced plans (not available on Professional)',
|
||||
href: 'https://mattermost.com/pricing/',
|
||||
},
|
||||
'pro-plus': {
|
||||
kind: 'plan',
|
||||
text: 'Available on',
|
||||
linkText: 'Professional, Enterprise, and Enterprise Advanced plans',
|
||||
href: 'https://mattermost.com/pricing/',
|
||||
},
|
||||
|
||||
// Academy callout cards
|
||||
'academy': {kind: 'academy', title: 'Free online training available', href: 'https://academy.mattermost.com'},
|
||||
'academy-calls': {kind: 'academy', title: 'Learn about Mattermost Calls', href: 'https://academy.mattermost.com/p/new-mattermost-copilot-multi-llm-setup-usage'},
|
||||
'academy-channels': {kind: 'academy', title: 'Learn about Mattermost channels', href: 'https://mattermost.com/pl/mattermost-academy-channels-training'},
|
||||
'academy-copilot-calls': {kind: 'academy', title: 'Learn about Mattermost Copilot and Calls', href: 'https://academy.mattermost.com/p/streamline-communication-with-mattermost-copilot-calls'},
|
||||
'academy-copilot-setup': {kind: 'academy', title: 'Learn about setting up and configuring Mattermost Copilot with multiple LLMs', href: 'https://academy.mattermost.com/p/new-mattermost-copilot-multi-llm-setup-usage'},
|
||||
'academy-customize-ui': {kind: 'academy', title: 'Learn what you can customize', href: 'https://mattermost.com/pl/mattermost-academy-customization-training'},
|
||||
'academy-file-storage': {kind: 'academy', title: 'Learn about file storage', href: 'https://mattermost.com/pl/mattermost-academy-configure-file-storage-training'},
|
||||
'academy-mattermost-database': {kind: 'academy', title: 'Learn about setting up the Mattermost database', href: 'https://mattermost.com/pl/mattermost-academy-database-configuration-training'},
|
||||
'academy-message-formatting': {kind: 'academy', title: 'Learn about message formatting', href: 'https://mattermost.com/pl/mattermost-academy-format-messages-training'},
|
||||
'academy-msteams': {kind: 'academy', title: 'Learn about integrating with Microsoft Teams', href: 'https://academy.mattermost.com/p/new-mattermost-for-microsoft-teams-integration'},
|
||||
'academy-notifications': {kind: 'academy', title: 'Learn about notifications', href: 'https://mattermost.com/pl/mattermost-academy-notifications-training'},
|
||||
'academy-platform-overview':{kind: 'academy', title: 'Learn about Mattermost', href: 'https://mattermost.com/pl/mattermost-academy-intro-training'},
|
||||
'academy-playbooks': {kind: 'academy', title: 'Learn about Mattermost Playbooks', href: 'https://academy.mattermost.com/p/mattermost-playbooks-onboarding-training'},
|
||||
'academy-search': {kind: 'academy', title: 'Learn about search', href: 'https://mattermost.com/pl/mattermost-academy-search-training'},
|
||||
'academy-tarball-deployment':{kind: 'academy', title: 'Learn about deploying Mattermost using a tarball', href: 'https://mattermost.com/pl/mattermost-academy-deploy-tarball-training'},
|
||||
'academy-tarball-upgrade': {kind: 'academy', title: 'Learn about upgrading Mattermost using a tarball', href: 'https://mattermost.com/pl/mattermost-academy-upgrade-tarball-training'},
|
||||
'academy-teams': {kind: 'academy', title: 'Learn about teams', href: 'https://academy.mattermost.com/p/new-mattermost-for-microsoft-teams-integration'},
|
||||
};
|
||||
|
||||
export default function PlanAvailability({slug}: {slug: string}) {
|
||||
const flagSrc = useBaseUrl('/img/badges/flag-yellow.svg');
|
||||
const academySrc = useBaseUrl('/img/badges/academy-callout.jpg');
|
||||
const data = BADGES[slug];
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className={`${styles.badge} ${styles.unknown}`} role="note">
|
||||
Unknown plan-availability slug: <code>{slug}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.kind === 'plan') {
|
||||
return (
|
||||
<aside className={`${styles.badge} ${styles.plan}`} role="note">
|
||||
<img src={flagSrc} alt="" className={styles.flag} aria-hidden />
|
||||
<span>
|
||||
{data.text}{' '}
|
||||
<Link href={data.href}>{data.linkText}</Link>
|
||||
{data.text.endsWith('on') && !data.linkText.includes('plans') ? ' plans' : ''}
|
||||
</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={data.href} className={`${styles.badge} ${styles.academy}`} target="_blank" rel="noopener">
|
||||
<img src={academySrc} alt="" className={styles.academyImg} aria-hidden />
|
||||
<div className={styles.academyCopy}>
|
||||
<span className={styles.academyAccent}>Mattermost Academy</span>
|
||||
<span className={styles.academyTitle}>{data.title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
92
docs/site/src/components/PlanAvailability/styles.module.css
Normal file
92
docs/site/src/components/PlanAvailability/styles.module.css
Normal file
@@ -0,0 +1,92 @@
|
||||
/* Plan-availability banner + Academy callout card.
|
||||
* Ports the visual contract of the Sphinx _static/badges/*.rst includes. */
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Plan-availability — flat denim-tinted strip, denim flag on the left
|
||||
* (matches the Edition/Deployment/Attestation badge family). */
|
||||
.plan {
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
|
||||
border-left: 3px solid var(--mm-color-denim, #1E325C);
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .plan {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-left-color: var(--mm-denim-300, #8499C5);
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
.plan a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flag {
|
||||
flex: 0 0 1.05rem;
|
||||
width: 1.05rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Academy callout — full-bleed card with image on the left, two stacked
|
||||
* lines of copy on the right. Whole card is the clickable link. */
|
||||
.academy {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
background: var(--mm-bg-surface);
|
||||
transition: box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.academy:hover {
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 12px rgba(30, 50, 92, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.academyImg {
|
||||
flex: 0 0 auto;
|
||||
width: 6rem;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.academyCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0.7rem 1rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.academyAccent {
|
||||
font-family: var(--mm-font-heading);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mm-color-marigold);
|
||||
}
|
||||
|
||||
.academyTitle {
|
||||
font-weight: 600;
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
.unknown {
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
border-left: 3px solid #ffc107;
|
||||
}
|
||||
17
docs/site/src/components/PlanBadge/index.tsx
Normal file
17
docs/site/src/components/PlanBadge/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type Plan = 'free' | 'professional' | 'enterprise' | 'cloud' | 'self-hosted';
|
||||
|
||||
const META: Record<Plan, {label: string; tone: 'neutral' | 'accent' | 'inverse'}> = {
|
||||
'free': {label: 'Free', tone: 'neutral'},
|
||||
'professional': {label: 'Professional', tone: 'accent'},
|
||||
'enterprise': {label: 'Enterprise', tone: 'inverse'},
|
||||
'cloud': {label: 'Cloud', tone: 'neutral'},
|
||||
'self-hosted': {label: 'Self-hosted', tone: 'neutral'},
|
||||
};
|
||||
|
||||
export default function PlanBadge({plan}: {plan: Plan}) {
|
||||
const m = META[plan];
|
||||
return <span className={`${styles.badge} ${styles[m.tone]}`}>{m.label}</span>;
|
||||
}
|
||||
33
docs/site/src/components/PlanBadge/styles.module.css
Normal file
33
docs/site/src/components/PlanBadge/styles.module.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.18rem 0.55rem;
|
||||
font-family: var(--mm-font-heading);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 2px;
|
||||
vertical-align: middle;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background: var(--mm-denim-50);
|
||||
color: var(--mm-denim-700);
|
||||
border: 1px solid var(--mm-denim-100);
|
||||
}
|
||||
[data-theme='dark'] .neutral {
|
||||
background: var(--mm-denim-800);
|
||||
color: var(--mm-denim-100);
|
||||
border-color: var(--mm-denim-700);
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: var(--mm-color-marigold);
|
||||
color: var(--mm-color-black);
|
||||
}
|
||||
|
||||
.inverse {
|
||||
background: var(--mm-color-denim);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
160
docs/site/src/components/PluginGoDocs/index.tsx
Normal file
160
docs/site/src/components/PluginGoDocs/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
// @ts-ignore — generated by `npm run build:plugin-godocs` (from docs/site) from
|
||||
// server/public/plugin; gitignored build output, see docs/site/.gitignore.
|
||||
import rawDocs from '@site/data/plugin-godocs.json';
|
||||
import type {GoDocs, GoInterfaceDocs} from './types';
|
||||
import {signatureText} from './types';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const docs: GoDocs | undefined = rawDocs && Object.keys(rawDocs).length > 0 ? (rawDocs as GoDocs) : undefined;
|
||||
|
||||
function MissingDataNotice() {
|
||||
return (
|
||||
<p>
|
||||
Run <code>npm run build:plugin-godocs</code> (from <code>docs/site</code>) to generate this
|
||||
documentation.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function InterfaceTOC({name, iface}: {name: string; iface: GoInterfaceDocs}) {
|
||||
const methods = iface.Methods ?? [];
|
||||
const untagged = methods.filter((m) => !m.Tags || m.Tags.length === 0);
|
||||
const tags = iface.Tags ?? [];
|
||||
|
||||
return (
|
||||
<ul className={styles.toc}>
|
||||
{untagged.map((m) => (
|
||||
<li key={m.Name}>
|
||||
<a href={`#${name}.${m.Name}`}>{signatureText(m)}</a>
|
||||
</li>
|
||||
))}
|
||||
{tags.map((tag) => (
|
||||
<li key={tag}>
|
||||
{tag}
|
||||
<ul>
|
||||
{methods
|
||||
.filter((m) => m.Tags?.includes(tag))
|
||||
.map((m) => (
|
||||
<li key={m.Name}>
|
||||
<a href={`#${name}.${m.Name}`}>{signatureText(m)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function InterfaceOverview({name, iface}: {name: string; iface: GoInterfaceDocs}) {
|
||||
if (!iface.HTML && (!iface.Methods || iface.Methods.length === 0)) return null;
|
||||
return (
|
||||
<div>
|
||||
<h2 id={name}>{name}</h2>
|
||||
{iface.HTML && <div dangerouslySetInnerHTML={{__html: iface.HTML}} />}
|
||||
<InterfaceTOC name={name} iface={iface} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InterfaceMethodDocs({name, iface}: {name: string; iface: GoInterfaceDocs}) {
|
||||
const methods = iface.Methods ?? [];
|
||||
if (methods.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<h2>{name}</h2>
|
||||
{methods.map((m) => (
|
||||
<div key={m.Name} className={styles.method}>
|
||||
<h3 id={`${name}.${m.Name}`}>
|
||||
({name}) {m.Name}
|
||||
</h3>
|
||||
<CodeBlock language="go">{signatureText(m)}</CodeBlock>
|
||||
{m.HTML && <div dangerouslySetInnerHTML={{__html: m.HTML}} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function exampleEntries(examples: Record<string, {HTML: string; Code: string}>) {
|
||||
return Object.entries(examples ?? {}).filter(([name]) => name.startsWith('_'));
|
||||
}
|
||||
|
||||
function ExamplesTOC({examples}: {examples: Record<string, {HTML: string; Code: string}>}) {
|
||||
const entries = exampleEntries(examples);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<h2 id="Examples">Examples</h2>
|
||||
<ul className={styles.toc}>
|
||||
{entries.map(([name]) => (
|
||||
<li key={name}>
|
||||
<a href={`#Examples.${name}`}>{displayName(name)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExamplesDocs({examples}: {examples: Record<string, {HTML: string; Code: string}>}) {
|
||||
const entries = exampleEntries(examples);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<h2>Examples</h2>
|
||||
{entries.map(([name, example]) => (
|
||||
<div key={name} className={styles.method}>
|
||||
<h3 id={`Examples.${name}`}>(Example) {displayName(name)}</h3>
|
||||
{example.HTML && <div dangerouslySetInnerHTML={{__html: example.HTML}} />}
|
||||
<CodeBlock language="go">{example.Code}</CodeBlock>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function displayName(name: string): string {
|
||||
const stripped = name.replace(/^_/, '');
|
||||
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the full server plugin SDK reference (API / Hooks interfaces, plus runnable examples)
|
||||
* generated from `server/public/plugin`'s Go doc comments by `gen-plugin-godocs`.
|
||||
*
|
||||
* Method signatures are rendered as plain, syntax-highlighted Go code (via the site's standard
|
||||
* `CodeBlock`) rather than HTML with inline pkg.go.dev links baked into the signature, for
|
||||
* consistent theming (including dark mode) and real Prism highlighting.
|
||||
*/
|
||||
export default function PluginGoDocs() {
|
||||
if (!docs) {
|
||||
return <MissingDataNotice />;
|
||||
}
|
||||
|
||||
const hasHelpers = (docs.Helpers?.Methods?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.pluginGoDocs}>
|
||||
{docs.HTML && <div dangerouslySetInnerHTML={{__html: docs.HTML}} />}
|
||||
<InterfaceOverview name="API" iface={docs.API} />
|
||||
<InterfaceOverview name="Hooks" iface={docs.Hooks} />
|
||||
{hasHelpers && <InterfaceOverview name="Helpers" iface={docs.Helpers} />}
|
||||
<ExamplesTOC examples={docs.Examples} />
|
||||
<hr />
|
||||
<InterfaceMethodDocs name="API" iface={docs.API} />
|
||||
<hr />
|
||||
<InterfaceMethodDocs name="Hooks" iface={docs.Hooks} />
|
||||
{hasHelpers && (
|
||||
<>
|
||||
<hr />
|
||||
<InterfaceMethodDocs name="Helpers" iface={docs.Helpers} />
|
||||
</>
|
||||
)}
|
||||
<hr />
|
||||
<ExamplesDocs examples={docs.Examples} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
docs/site/src/components/PluginGoDocs/styles.module.css
Normal file
29
docs/site/src/components/PluginGoDocs/styles.module.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.pluginGoDocs h2 {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--mm-border-subtle);
|
||||
}
|
||||
|
||||
.toc {
|
||||
columns: 2;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
|
||||
.toc li {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.method {
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
|
||||
.method h3 {
|
||||
font-family: var(--mm-font-mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toc {
|
||||
columns: 1;
|
||||
}
|
||||
}
|
||||
82
docs/site/src/components/PluginGoDocs/types.ts
Normal file
82
docs/site/src/components/PluginGoDocs/types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Shape produced by docs/site/scripts/gen-plugin-godocs — kept in sync manually since the
|
||||
// generator is Go and can't share TS types directly.
|
||||
export type GoField = {
|
||||
Names?: string[];
|
||||
Type: string;
|
||||
};
|
||||
|
||||
export type GoMethodDocs = {
|
||||
Name: string;
|
||||
Tags?: string[];
|
||||
HTML: string;
|
||||
Parameters?: GoField[];
|
||||
Results?: GoField[];
|
||||
};
|
||||
|
||||
export type GoInterfaceDocs = {
|
||||
HTML: string;
|
||||
Tags?: string[] | null;
|
||||
Methods?: GoMethodDocs[] | null;
|
||||
};
|
||||
|
||||
export type GoExampleDocs = {
|
||||
HTML: string;
|
||||
Code: string;
|
||||
};
|
||||
|
||||
export type GoDocs = {
|
||||
HTML: string;
|
||||
API: GoInterfaceDocs;
|
||||
Hooks: GoInterfaceDocs;
|
||||
Helpers: GoInterfaceDocs;
|
||||
Examples: Record<string, GoExampleDocs>;
|
||||
};
|
||||
|
||||
// "[]" / "*" / "..." prefixes, and package-qualified names (e.g.
|
||||
// "github.com/mattermost/mattermost/server/public/model.Manifest"), mirroring the old Hugo
|
||||
// `TypeString` shortcode define — take the last "/"-delimited segment, keeping the "pkg.Type"
|
||||
// suffix intact.
|
||||
export function typeText(type: string): string {
|
||||
if (type.startsWith('[]')) return '[]' + typeText(type.slice(2));
|
||||
if (type.startsWith('*')) return '*' + typeText(type.slice(1));
|
||||
if (type.startsWith('...')) return '...' + typeText(type.slice(3));
|
||||
if (type.startsWith('map[')) {
|
||||
let depth = 0;
|
||||
let i = 4;
|
||||
for (; i < type.length; i++) {
|
||||
if (type[i] === '[') depth++;
|
||||
else if (type[i] === ']') {
|
||||
if (depth === 0) break;
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return `map[${typeText(type.slice(4, i))}]${typeText(type.slice(i + 1))}`;
|
||||
}
|
||||
const parts = type.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
export function fieldsText(fields?: GoField[]): string {
|
||||
if (!fields || fields.length === 0) return '';
|
||||
return fields
|
||||
.map((f) => (f.Names?.length ? `${f.Names.join(', ')} ` : '') + typeText(f.Type))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export function resultsText(results?: GoField[]): string {
|
||||
if (!results || results.length === 0) return '';
|
||||
const needsParens = results.length > 1 || Boolean(results[0].Names?.length);
|
||||
const inner = fieldsText(results);
|
||||
return needsParens ? `(${inner})` : inner;
|
||||
}
|
||||
|
||||
export function signatureText(method: GoMethodDocs): string {
|
||||
const params = fieldsText(method.Parameters);
|
||||
const results = resultsText(method.Results);
|
||||
return `${method.Name}(${params})${results ? ' ' + results : ''}`;
|
||||
}
|
||||
|
||||
export function displayExampleName(name: string): string {
|
||||
const stripped = name.replace(/^_/, '');
|
||||
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
||||
}
|
||||
26
docs/site/src/components/PluginGoExample/index.tsx
Normal file
26
docs/site/src/components/PluginGoExample/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
// @ts-ignore — generated by `npm run build:plugin-godocs` (from docs/site); gitignored build
|
||||
// output, see docs/site/.gitignore.
|
||||
import rawDocs from '@site/data/plugin-godocs.json';
|
||||
import type {GoDocs} from '../PluginGoDocs/types';
|
||||
|
||||
const docs: GoDocs | undefined = rawDocs && Object.keys(rawDocs).length > 0 ? (rawDocs as GoDocs) : undefined;
|
||||
|
||||
/**
|
||||
* Renders a single Go example's source code, sourced from the same generated data as
|
||||
* `<PluginGoDocs />`, e.g. `<PluginGoExample name="_helloWorld" />` renders the body of
|
||||
* `Example_helloWorld`.
|
||||
*/
|
||||
export default function PluginGoExample({name}: {name: string}) {
|
||||
const example = docs?.Examples?.[name];
|
||||
if (!example) {
|
||||
return (
|
||||
<p>
|
||||
Run <code>npm run build:plugin-godocs</code> (from <code>docs/site</code>) to generate this
|
||||
example code.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <CodeBlock language="go">{example.Code}</CodeBlock>;
|
||||
}
|
||||
61
docs/site/src/components/PluginJsDocs/index.tsx
Normal file
61
docs/site/src/components/PluginJsDocs/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
// @ts-ignore — generated by `npm run build:plugin-jsdocs` (from docs/site) from
|
||||
// webapp/channels/src/plugins/registry.ts; gitignored build output, see docs/site/.gitignore.
|
||||
import rawDocs from '@site/data/plugin-jsdocs.json';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type JsMethodDocs = {
|
||||
Name: string;
|
||||
Parameters: string[];
|
||||
Comments: string[];
|
||||
};
|
||||
|
||||
type JsDocs = {
|
||||
Interface: {
|
||||
Methods: JsMethodDocs[];
|
||||
};
|
||||
};
|
||||
|
||||
const docs: JsDocs | undefined = rawDocs?.Interface?.Methods?.length > 0 ? (rawDocs as JsDocs) : undefined;
|
||||
|
||||
function signature(method: JsMethodDocs): string {
|
||||
return `${method.Name}(${method.Parameters.join(', ')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the web app plugin registry method reference, generated from the JSDoc comments above
|
||||
* each `PluginRegistry` method/property in `webapp/channels/src/plugins/registry.ts` by
|
||||
* `gen-plugin-jsdocs`.
|
||||
*/
|
||||
export default function PluginJsDocs() {
|
||||
if (!docs) {
|
||||
return (
|
||||
<p>
|
||||
Run <code>npm run build:plugin-jsdocs</code> (from <code>docs/site</code>) to generate this
|
||||
documentation.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const methods = docs.Interface.Methods;
|
||||
|
||||
return (
|
||||
<div className={styles.pluginJsDocs}>
|
||||
<ul className={styles.toc}>
|
||||
{methods.map((m) => (
|
||||
<li key={m.Name}>
|
||||
<a href={`#${m.Name}`}>{signature(m)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<hr />
|
||||
{methods.map((m) => (
|
||||
<div key={m.Name} className={styles.method}>
|
||||
<h3 id={m.Name}>{m.Name}</h3>
|
||||
<CodeBlock language="js">{`/**\n${m.Comments.map((c) => ` * ${c}`).join('\n')}\n */\n${signature(m)}`}</CodeBlock>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
docs/site/src/components/PluginJsDocs/styles.module.css
Normal file
23
docs/site/src/components/PluginJsDocs/styles.module.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.toc {
|
||||
columns: 2;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
|
||||
.toc li {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.method {
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
|
||||
.method h3 {
|
||||
font-family: var(--mm-font-mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toc {
|
||||
columns: 1;
|
||||
}
|
||||
}
|
||||
110
docs/site/src/components/PluginManifestDocs/index.tsx
Normal file
110
docs/site/src/components/PluginManifestDocs/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
// @ts-ignore — generated by `npm run build:plugin-manifest-docs` (from docs/site) from
|
||||
// server/public/model's Manifest struct; gitignored build output, see docs/site/.gitignore.
|
||||
import rawDocs from '@site/data/plugin-manifest-docs.json';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type SchemaType = 'array' | 'bool' | 'dict' | 'number' | 'object' | 'string' | 'interface';
|
||||
|
||||
type ObjectProperty = {
|
||||
Name: string;
|
||||
DocHTML?: string;
|
||||
Schema?: TypeDocs;
|
||||
};
|
||||
|
||||
type TypeDocs = {
|
||||
Type: SchemaType;
|
||||
DocHTML?: string;
|
||||
ObjectProperties?: ObjectProperty[];
|
||||
ValueSchema?: TypeDocs;
|
||||
};
|
||||
|
||||
type ManifestDocs = {
|
||||
Schema: TypeDocs;
|
||||
};
|
||||
|
||||
const docs: ManifestDocs | undefined = rawDocs?.Schema ? (rawDocs as ManifestDocs) : undefined;
|
||||
|
||||
function titleCase(type: SchemaType): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
// Arrays/dicts have no properties of their own — the ToC/Docs walk passes through to their
|
||||
// element schema, same as the old Hugo `pluginmanifestdocs` shortcode.
|
||||
function TOC({schema, prefix}: {schema?: TypeDocs; prefix: string}) {
|
||||
if (!schema) return null;
|
||||
if (schema.Type === 'object') {
|
||||
return (
|
||||
<ul>
|
||||
{(schema.ObjectProperties ?? []).map((prop) => (
|
||||
<li key={prop.Name}>
|
||||
<a href={`#${prefix}${prop.Name}`}>
|
||||
<code>{prop.Name}</code>
|
||||
</a>
|
||||
{prop.Schema && ` - ${titleCase(prop.Schema.Type)}`}
|
||||
{prop.Schema && <TOC schema={prop.Schema} prefix={`${prefix}${prop.Name}.`} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (schema.Type === 'array' || schema.Type === 'dict') {
|
||||
return <TOC schema={schema.ValueSchema} prefix={prefix} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Docs({schema, prefix}: {schema?: TypeDocs; prefix: string}) {
|
||||
if (!schema) return null;
|
||||
if (schema.Type === 'object') {
|
||||
return (
|
||||
<ul className={styles.docsList}>
|
||||
{(schema.ObjectProperties ?? []).map((prop) => (
|
||||
<li key={prop.Name} id={`${prefix}${prop.Name}`}>
|
||||
<p>
|
||||
<code>{prop.Name}</code>
|
||||
{prop.Schema && ` - ${titleCase(prop.Schema.Type)}`}
|
||||
</p>
|
||||
{prop.DocHTML && <div dangerouslySetInnerHTML={{__html: prop.DocHTML}} />}
|
||||
{prop.Schema?.DocHTML && <div dangerouslySetInnerHTML={{__html: prop.Schema.DocHTML}} />}
|
||||
{prop.Schema && <Docs schema={prop.Schema} prefix={`${prefix}${prop.Name}.`} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (schema.Type === 'array' || schema.Type === 'dict') {
|
||||
return (
|
||||
<>
|
||||
{schema.ValueSchema?.DocHTML && <div dangerouslySetInnerHTML={{__html: schema.ValueSchema.DocHTML}} />}
|
||||
<Docs schema={schema.ValueSchema} prefix={prefix} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the plugin.json/plugin.yaml manifest field reference, generated from
|
||||
* `model.Manifest`'s Go doc comments by `gen-plugin-manifest-docs`.
|
||||
*/
|
||||
export default function PluginManifestDocs() {
|
||||
if (!docs) {
|
||||
return (
|
||||
<p>
|
||||
Run <code>npm run build:plugin-manifest-docs</code> (from <code>docs/site</code>) to
|
||||
generate this documentation.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.pluginManifestDocs}>
|
||||
{docs.Schema.DocHTML && <div dangerouslySetInnerHTML={{__html: docs.Schema.DocHTML}} />}
|
||||
<h2>Table of contents</h2>
|
||||
<TOC schema={docs.Schema} prefix="" />
|
||||
<h2>Documentation</h2>
|
||||
<Docs schema={docs.Schema} prefix="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.docsList {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.docsList li {
|
||||
margin: 1.25rem 0;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--mm-border-subtle);
|
||||
}
|
||||
|
||||
.docsList code {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
158
docs/site/src/components/SecurityUpdates/feed.ts
Normal file
158
docs/site/src/components/SecurityUpdates/feed.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {parseAffectedLines, type VersionRange} from './versions';
|
||||
|
||||
export const FEED_URL = 'https://securityupdates.mattermost.com/security_updates.json';
|
||||
|
||||
export type TabId = 'server' | 'desktop' | 'mobile' | 'plugins';
|
||||
|
||||
export type SecurityUpdateRow = {
|
||||
issueId: string;
|
||||
cveId?: string;
|
||||
severity: string;
|
||||
affectedVersions: string[];
|
||||
affectedRanges: VersionRange[];
|
||||
releaseDate: string;
|
||||
fixVersions: string[];
|
||||
details: string;
|
||||
};
|
||||
|
||||
export type SecurityUpdateTab = {
|
||||
id: TabId;
|
||||
label: string;
|
||||
rows: SecurityUpdateRow[];
|
||||
};
|
||||
|
||||
export const TAB_ORDER: {id: TabId; label: string}[] = [
|
||||
{id: 'server', label: 'Server'},
|
||||
{id: 'desktop', label: 'Desktop'},
|
||||
{id: 'mobile', label: 'Mobile'},
|
||||
{id: 'plugins', label: 'Plugins'},
|
||||
];
|
||||
|
||||
const PLATFORM_MAP: Record<string, TabId> = {
|
||||
'Mattermost Server': 'server',
|
||||
'Mattermost Desktop App': 'desktop',
|
||||
'Mattermost Mobile Apps': 'mobile',
|
||||
'Mattermost Plugins': 'plugins',
|
||||
};
|
||||
|
||||
type FeedItem = {
|
||||
issue_id?: string;
|
||||
cve_id?: string;
|
||||
severity?: string;
|
||||
affected_versions?: unknown;
|
||||
fix_release_date?: string;
|
||||
fix_versions?: unknown;
|
||||
details?: string;
|
||||
platform?: string;
|
||||
};
|
||||
|
||||
function normalizeVersions(value: unknown): string[] {
|
||||
if (value == null || value === '') {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry) => normalizeVersions(entry)).map((entry) => entry.trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
return normalizeVersions(JSON.parse(trimmed));
|
||||
} catch {
|
||||
/* keep as plain string */
|
||||
}
|
||||
}
|
||||
return [trimmed];
|
||||
}
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
function normalizeSeverity(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || /^n\/?a$/i.test(trimmed)) {
|
||||
return 'N/A';
|
||||
}
|
||||
return trimmed.replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function parseIssueParts(issueId: string) {
|
||||
const parts = issueId.split('-');
|
||||
return {
|
||||
year: Number.parseInt(parts[1] || '', 10) || 0,
|
||||
num: Number.parseInt(parts[2] || '', 10) || 0,
|
||||
extra: Number.parseInt(parts[3] || '', 10) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toRow(item: FeedItem): SecurityUpdateRow {
|
||||
const cveId = String(item.cve_id || '').trim();
|
||||
const affectedVersions = normalizeVersions(item.affected_versions);
|
||||
const fixVersions = normalizeVersions(item.fix_versions);
|
||||
return {
|
||||
issueId: String(item.issue_id || '').trim(),
|
||||
...(cveId ? {cveId} : {}),
|
||||
severity: normalizeSeverity(String(item.severity || '')),
|
||||
affectedVersions,
|
||||
affectedRanges: parseAffectedLines(affectedVersions, fixVersions),
|
||||
releaseDate: String(item.fix_release_date || '').slice(0, 10),
|
||||
fixVersions,
|
||||
details: String(item.details || '').replace(/\s+/g, ' ').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function compareFeedItems(a: FeedItem, b: FeedItem): number {
|
||||
const dateA = Date.parse(String(a.fix_release_date || '').slice(0, 10)) || 0;
|
||||
const dateB = Date.parse(String(b.fix_release_date || '').slice(0, 10)) || 0;
|
||||
if (dateA !== dateB) {
|
||||
return dateB - dateA;
|
||||
}
|
||||
const pa = parseIssueParts(String(a.issue_id || ''));
|
||||
const pb = parseIssueParts(String(b.issue_id || ''));
|
||||
if (pa.year !== pb.year) {
|
||||
return pb.year - pa.year;
|
||||
}
|
||||
if (pa.num !== pb.num) {
|
||||
return pb.num - pa.num;
|
||||
}
|
||||
return pb.extra - pa.extra;
|
||||
}
|
||||
|
||||
export function normalizeFeed(feed: unknown): SecurityUpdateTab[] {
|
||||
if (!Array.isArray(feed)) {
|
||||
throw new Error('Unexpected feed shape: expected an array of advisories.');
|
||||
}
|
||||
|
||||
const tabs = Object.fromEntries(
|
||||
TAB_ORDER.map((tab) => [tab.id, {...tab, rows: [] as SecurityUpdateRow[]}]),
|
||||
) as Record<TabId, SecurityUpdateTab>;
|
||||
|
||||
[...(feed as FeedItem[])]
|
||||
.sort(compareFeedItems)
|
||||
.forEach((item) => {
|
||||
const tabId = PLATFORM_MAP[String(item.platform || '').trim()];
|
||||
if (!tabId) {
|
||||
return;
|
||||
}
|
||||
tabs[tabId].rows.push(toRow(item));
|
||||
});
|
||||
|
||||
return TAB_ORDER.map((tab) => tabs[tab.id]);
|
||||
}
|
||||
|
||||
export async function fetchSecurityUpdates(): Promise<{
|
||||
tabs: SecurityUpdateTab[];
|
||||
lastModified: string | null;
|
||||
}> {
|
||||
const response = await fetch(FEED_URL, {cache: 'no-store'});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Security updates feed returned ${response.status}.`);
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
return {
|
||||
tabs: normalizeFeed(payload),
|
||||
lastModified: response.headers.get('Last-Modified'),
|
||||
};
|
||||
}
|
||||
608
docs/site/src/components/SecurityUpdates/index.tsx
Normal file
608
docs/site/src/components/SecurityUpdates/index.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import React, {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {
|
||||
fetchSecurityUpdates,
|
||||
TAB_ORDER,
|
||||
type SecurityUpdateRow,
|
||||
type SecurityUpdateTab,
|
||||
type TabId,
|
||||
} from './feed';
|
||||
import styles from './styles.module.css';
|
||||
import {labelToVersion, rowMatchesVersionRange, type Version} from './versions';
|
||||
|
||||
type SortKey = 'issueId' | 'severity' | 'affectedVersions' | 'releaseDate' | 'fixVersions' | 'details';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
const PAGE_SIZES = [10, 25, 50, 100];
|
||||
const SEVERITY_OPTIONS = ['Critical', 'High', 'Medium', 'Low', 'N/A'] as const;
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
Critical: 4,
|
||||
High: 3,
|
||||
Medium: 2,
|
||||
Low: 1,
|
||||
'N/A': 0,
|
||||
};
|
||||
|
||||
const VERSION_FILTER_DEBOUNCE_MS = 250;
|
||||
|
||||
const EMPTY_FILTERS = {
|
||||
issueId: '',
|
||||
severity: '',
|
||||
affectedVersion: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
fixVersions: '',
|
||||
search: '',
|
||||
};
|
||||
|
||||
function parseIssueSort(issueId: string): number[] {
|
||||
const parts = issueId.split('-');
|
||||
return [
|
||||
Number.parseInt(parts[1] || '', 10) || 0,
|
||||
Number.parseInt(parts[2] || '', 10) || 0,
|
||||
Number.parseInt(parts[3] || '', 10) || 0,
|
||||
];
|
||||
}
|
||||
|
||||
function firstVersionLabel(lines: string[]): string {
|
||||
return lines[0] || '';
|
||||
}
|
||||
|
||||
function sortRows(rows: SecurityUpdateRow[], key: SortKey, dir: SortDir): SecurityUpdateRow[] {
|
||||
const copy = [...rows];
|
||||
copy.sort((a, b) => {
|
||||
let result = 0;
|
||||
if (key === 'issueId') {
|
||||
const pa = parseIssueSort(a.issueId);
|
||||
const pb = parseIssueSort(b.issueId);
|
||||
result = pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2] || a.issueId.localeCompare(b.issueId);
|
||||
} else if (key === 'severity') {
|
||||
result = (SEVERITY_RANK[a.severity] ?? -1) - (SEVERITY_RANK[b.severity] ?? -1);
|
||||
} else if (key === 'releaseDate') {
|
||||
result = (a.releaseDate || '').localeCompare(b.releaseDate || '');
|
||||
} else if (key === 'affectedVersions') {
|
||||
result = firstVersionLabel(a.affectedVersions).localeCompare(firstVersionLabel(b.affectedVersions));
|
||||
} else if (key === 'fixVersions') {
|
||||
result = firstVersionLabel(a.fixVersions).localeCompare(firstVersionLabel(b.fixVersions));
|
||||
} else {
|
||||
result = a.details.localeCompare(b.details);
|
||||
}
|
||||
return dir === 'asc' ? result : -result;
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
function rowMatchesFilters(
|
||||
row: SecurityUpdateRow,
|
||||
filters: typeof EMPTY_FILTERS,
|
||||
minV: Version | null,
|
||||
maxV: Version | null,
|
||||
): boolean {
|
||||
const search = filters.search.trim().toLowerCase();
|
||||
if (search) {
|
||||
const haystack = [
|
||||
row.issueId,
|
||||
row.cveId,
|
||||
row.severity,
|
||||
row.releaseDate,
|
||||
row.details,
|
||||
...row.affectedVersions,
|
||||
...row.fixVersions,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
if (!haystack.includes(search)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.issueId.trim()) {
|
||||
const q = filters.issueId.trim().toLowerCase();
|
||||
const issueHay = `${row.issueId} ${row.cveId || ''}`.toLowerCase();
|
||||
if (!issueHay.includes(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.severity && row.severity !== filters.severity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.fixVersions.trim()) {
|
||||
const q = filters.fixVersions.trim().toLowerCase();
|
||||
if (!row.fixVersions.some((line) => line.toLowerCase().includes(q))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.dateFrom && row.releaseDate && row.releaseDate < filters.dateFrom) {
|
||||
return false;
|
||||
}
|
||||
if (filters.dateTo && row.releaseDate && row.releaseDate > filters.dateTo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!rowMatchesVersionRange(row.affectedRanges, minV, maxV)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPagination(current: number, total: number): (number | '…')[] {
|
||||
if (total <= 7) {
|
||||
return Array.from({length: total}, (_, i) => i + 1);
|
||||
}
|
||||
const pages: (number | '…')[] = [1];
|
||||
const start = Math.max(2, current - 2);
|
||||
const end = Math.min(total - 1, current + 2);
|
||||
if (start > 2) {
|
||||
pages.push('…');
|
||||
}
|
||||
for (let page = start; page <= end; page += 1) {
|
||||
pages.push(page);
|
||||
}
|
||||
if (end < total - 1) {
|
||||
pages.push('…');
|
||||
}
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
function VersionList({lines}: {lines: string[]}) {
|
||||
if (lines.length === 0) {
|
||||
return <span className={styles.muted}>—</span>;
|
||||
}
|
||||
return (
|
||||
<ul className={styles.versionList}>
|
||||
{lines.map((line, index) => (
|
||||
<li key={`${line}-${index}`}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function rowKey(row: SecurityUpdateRow): string {
|
||||
return `${row.issueId}|${row.releaseDate}|${row.affectedVersions.join(';')}|${row.fixVersions.join(';')}`;
|
||||
}
|
||||
|
||||
export default function SecurityUpdates() {
|
||||
const formId = useId();
|
||||
const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabs, setTabs] = useState<SecurityUpdateTab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('server');
|
||||
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
||||
const [versionDraft, setVersionDraft] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('releaseDate');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const loadGeneration = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const generation = (loadGeneration.current += 1);
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchSecurityUpdates();
|
||||
if (generation !== loadGeneration.current) {
|
||||
return;
|
||||
}
|
||||
setTabs(result.tabs);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
if (generation !== loadGeneration.current) {
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Unable to load security updates.');
|
||||
setStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const activeRows = useMemo(
|
||||
() => tabs.find((tab) => tab.id === activeTab)?.rows ?? [],
|
||||
[tabs, activeTab],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setFilters((prev) => {
|
||||
if (prev.affectedVersion === versionDraft) {
|
||||
return prev;
|
||||
}
|
||||
return {...prev, affectedVersion: versionDraft};
|
||||
});
|
||||
}, VERSION_FILTER_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [versionDraft]);
|
||||
|
||||
const dateOrderError = useMemo(() => {
|
||||
if (filters.dateFrom && filters.dateTo && filters.dateFrom > filters.dateTo) {
|
||||
return 'Fix released to must be on or after fix released from.';
|
||||
}
|
||||
return null;
|
||||
}, [filters.dateFrom, filters.dateTo]);
|
||||
|
||||
const affectedVersionError = useMemo(() => {
|
||||
const trimmed = filters.affectedVersion.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (!labelToVersion(trimmed, 'min')) {
|
||||
return 'Enter a version such as 10.11 or 10.11.23.';
|
||||
}
|
||||
return null;
|
||||
}, [filters.affectedVersion]);
|
||||
|
||||
const filterError = affectedVersionError || dateOrderError;
|
||||
const filterErrorId = `${formId}-filter-error`;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (filterError) {
|
||||
return [];
|
||||
}
|
||||
const minV = labelToVersion(filters.affectedVersion, 'min');
|
||||
const maxV = labelToVersion(filters.affectedVersion, 'max');
|
||||
return sortRows(
|
||||
activeRows.filter((row) => rowMatchesFilters(row, filters, minV, maxV)),
|
||||
sortKey,
|
||||
sortDir,
|
||||
);
|
||||
}, [activeRows, filters, sortKey, sortDir, filterError]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [activeTab, filters, pageSize, sortKey, sortDir]);
|
||||
|
||||
const hasActiveFilters = useMemo(
|
||||
() =>
|
||||
Object.values(filters).some((value) => value.trim() !== '') || versionDraft.trim() !== '',
|
||||
[filters, versionDraft],
|
||||
);
|
||||
|
||||
const setFilter = (key: keyof typeof EMPTY_FILTERS, value: string) => {
|
||||
setFilters((prev) => ({...prev, [key]: value}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(EMPTY_FILTERS);
|
||||
setVersionDraft('');
|
||||
};
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDir(key === 'releaseDate' || key === 'severity' || key === 'issueId' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
const sortProps = (key: SortKey) => ({
|
||||
'aria-sort': sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none',
|
||||
}) as const;
|
||||
|
||||
const renderSortButton = (key: SortKey, label: string) => {
|
||||
const direction =
|
||||
sortKey === key ? (sortDir === 'asc' ? 'sorted ascending' : 'sorted descending') : 'not sorted';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortButton}
|
||||
onClick={() => toggleSort(key)}
|
||||
aria-label={`${label}, ${direction}`}
|
||||
>
|
||||
{label}
|
||||
<span className={styles.sortIcons} aria-hidden>
|
||||
<span className={sortKey === key && sortDir === 'asc' ? styles.sortActive : undefined}>▲</span>
|
||||
<span className={sortKey === key && sortDir === 'desc' ? styles.sortActive : undefined}>▼</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.tabs} role="tablist" aria-label="Product">
|
||||
{TAB_ORDER.map((tab) => {
|
||||
const selected = tab.id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${formId}-tab-${tab.id}`}
|
||||
aria-selected={selected}
|
||||
aria-controls={`${formId}-panel`}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
className={selected ? `${styles.tab} ${styles.tabActive}` : styles.tab}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onKeyDown={(event) => {
|
||||
const order = TAB_ORDER.map((item) => item.id);
|
||||
const index = order.indexOf(tab.id);
|
||||
let next = -1;
|
||||
if (event.key === 'ArrowRight') {
|
||||
next = (index + 1) % order.length;
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
next = (index - 1 + order.length) % order.length;
|
||||
} else if (event.key === 'Home') {
|
||||
next = 0;
|
||||
} else if (event.key === 'End') {
|
||||
next = order.length - 1;
|
||||
}
|
||||
if (next < 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setActiveTab(order[next]);
|
||||
document.getElementById(`${formId}-tab-${order[next]}`)?.focus();
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`${formId}-panel`}
|
||||
aria-labelledby={`${formId}-tab-${activeTab}`}
|
||||
>
|
||||
{status === 'loading' && (
|
||||
<div className={styles.status} role="status">
|
||||
Loading security updates…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className={styles.error} role="alert">
|
||||
<p>{error}</p>
|
||||
<button type="button" className={styles.primaryButton} onClick={() => void load()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'ready' && (
|
||||
<>
|
||||
<form
|
||||
className={styles.filters}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
aria-label="Filter security updates"
|
||||
>
|
||||
<label className={styles.searchField}>
|
||||
Search all columns
|
||||
<input
|
||||
type="search"
|
||||
value={filters.search}
|
||||
onChange={(event) => setFilter('search', event.target.value)}
|
||||
placeholder="Issue ID, CVE, version, or details"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Issue ID / CVE
|
||||
<input
|
||||
type="search"
|
||||
value={filters.issueId}
|
||||
onChange={(event) => setFilter('issueId', event.target.value)}
|
||||
placeholder="MMSA or CVE"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Severity
|
||||
<select
|
||||
value={filters.severity}
|
||||
onChange={(event) => setFilter('severity', event.target.value)}
|
||||
>
|
||||
<option value="">All severities</option>
|
||||
{SEVERITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Affected version
|
||||
<input
|
||||
value={versionDraft}
|
||||
onChange={(event) => setVersionDraft(event.target.value)}
|
||||
placeholder="e.g. 10.11.23"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(affectedVersionError)}
|
||||
aria-describedby={
|
||||
affectedVersionError
|
||||
? `${filterErrorId} ${formId}-affected-hint`
|
||||
: `${formId}-affected-hint`
|
||||
}
|
||||
/>
|
||||
<small id={`${formId}-affected-hint`} className={styles.fieldHint}>
|
||||
Enter the version you’re running to see affected advisories.
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Fix released from
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
onChange={(event) => setFilter('dateFrom', event.target.value)}
|
||||
aria-invalid={Boolean(dateOrderError)}
|
||||
aria-describedby={dateOrderError ? filterErrorId : undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Fix released to
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
onChange={(event) => setFilter('dateTo', event.target.value)}
|
||||
aria-invalid={Boolean(dateOrderError)}
|
||||
aria-describedby={dateOrderError ? filterErrorId : undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Fix versions
|
||||
<input
|
||||
type="search"
|
||||
value={filters.fixVersions}
|
||||
onChange={(event) => setFilter('fixVersions', event.target.value)}
|
||||
placeholder="e.g. 10.11.23"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.filterActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.resetButton}
|
||||
onClick={clearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
{filterError ? (
|
||||
<p className={styles.filterError} id={filterErrorId} role="alert">
|
||||
{filterError}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<label className={styles.pageSize}>
|
||||
Rows
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(event) => setPageSize(Number.parseInt(event.target.value, 10) || 25)}
|
||||
>
|
||||
{PAGE_SIZES.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={styles.summary} role="status">
|
||||
{filteredRows.length === 0
|
||||
? 'No matching advisories'
|
||||
: `Showing ${(currentPage - 1) * pageSize + 1}–${(currentPage - 1) * pageSize + pageRows.length} of ${filteredRows.length}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table} aria-label={`${TAB_ORDER.find((tab) => tab.id === activeTab)?.label} security updates`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" {...sortProps('issueId')}>
|
||||
{renderSortButton('issueId', 'Issue ID')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('severity')}>
|
||||
{renderSortButton('severity', 'Severity')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('affectedVersions')}>
|
||||
{renderSortButton('affectedVersions', 'Affected versions')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('releaseDate')}>
|
||||
{renderSortButton('releaseDate', 'Release date')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('fixVersions')}>
|
||||
{renderSortButton('fixVersions', 'Fix versions')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('details')}>
|
||||
{renderSortButton('details', 'Details')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className={styles.empty}>
|
||||
No security updates match these filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pageRows.map((row) => (
|
||||
<tr key={rowKey(row)}>
|
||||
<td data-label="Issue ID">
|
||||
<span className={styles.issueId}>{row.issueId}</span>
|
||||
{row.cveId ? (
|
||||
<a
|
||||
className={styles.cve}
|
||||
href={`https://nvd.nist.gov/vuln/detail/${row.cveId}`}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{row.cveId}
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td data-label="Severity">
|
||||
<span
|
||||
className={`${styles.badge} ${
|
||||
styles[`badge${row.severity.replace(/[^A-Za-z]/g, '')}` as keyof typeof styles] ||
|
||||
styles.badgeNA
|
||||
}`}
|
||||
>
|
||||
{row.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="Affected versions">
|
||||
<VersionList lines={row.affectedVersions} />
|
||||
</td>
|
||||
<td data-label="Release date">{row.releaseDate || '—'}</td>
|
||||
<td data-label="Fix versions">
|
||||
<VersionList lines={row.fixVersions} />
|
||||
</td>
|
||||
<td data-label="Details">{row.details || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className={styles.pagination} aria-label="Security updates pages">
|
||||
<ol>
|
||||
{buildPagination(currentPage, totalPages).map((item, index) => (
|
||||
<li key={`${item}-${index}`}>
|
||||
{item === '…' ? (
|
||||
<span className={styles.ellipsis}>…</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={item === currentPage ? styles.pageActive : undefined}
|
||||
aria-label={`Page ${item}`}
|
||||
aria-current={item === currentPage ? 'page' : undefined}
|
||||
onClick={() => setPage(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
458
docs/site/src/components/SecurityUpdates/styles.module.css
Normal file
458
docs/site/src/components/SecurityUpdates/styles.module.css
Normal file
@@ -0,0 +1,458 @@
|
||||
.root {
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.95rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
background: var(--mm-bg-surface);
|
||||
color: var(--mm-text-primary);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--mm-focus-ring);
|
||||
outline-offset: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
background: var(--mm-color-denim);
|
||||
border-color: var(--mm-color-denim);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .tabActive {
|
||||
background: var(--mm-denim-500);
|
||||
border-color: var(--mm-denim-500);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 1rem 1.15rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 4px;
|
||||
background: var(--mm-bg-subtle);
|
||||
}
|
||||
|
||||
.filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.filters input,
|
||||
.filters select,
|
||||
.pageSize select {
|
||||
width: 100%;
|
||||
min-height: 2.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 4px;
|
||||
background: var(--mm-bg-surface);
|
||||
color: var(--mm-text-primary);
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .filters input,
|
||||
[data-theme='dark'] .filters select,
|
||||
[data-theme='dark'] .pageSize select {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.filters input:focus-visible,
|
||||
.filters select:focus-visible,
|
||||
.pageSize select:focus-visible,
|
||||
.sortButton:focus-visible,
|
||||
.primaryButton:focus-visible,
|
||||
.resetButton:focus-visible,
|
||||
.pagination button:focus-visible {
|
||||
outline: 2px solid var(--mm-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.searchField {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.filterActions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
flex: 1 1 16rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 400;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.fieldHint code {
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: var(--mm-bg-surface);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.filterError {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #8b1f1f;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .filterError {
|
||||
color: #f4a0ab;
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.resetButton {
|
||||
padding: 0.5rem 0.95rem;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
border: none;
|
||||
background: var(--mm-color-denim);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
background: var(--mm-denim-600);
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
background: transparent;
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
.resetButton:hover:not(:disabled) {
|
||||
background: var(--mm-bg-surface);
|
||||
}
|
||||
|
||||
.resetButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.pageSize {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pageSize select {
|
||||
width: auto;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
background: var(--mm-bg-surface);
|
||||
}
|
||||
|
||||
.tableWrap .table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: table;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.7rem 0.75rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
border-bottom: 1px solid var(--mm-border-subtle);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: var(--mm-denim-50);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .table th {
|
||||
background: var(--mm-denim-800);
|
||||
}
|
||||
|
||||
.table tbody tr:nth-child(even) {
|
||||
background: var(--mm-bg-subtle);
|
||||
}
|
||||
|
||||
.table th:nth-child(1),
|
||||
.table td:nth-child(1) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.table th:nth-child(2),
|
||||
.table td:nth-child(2) {
|
||||
width: 9%;
|
||||
}
|
||||
|
||||
.table th:nth-child(3),
|
||||
.table td:nth-child(3),
|
||||
.table th:nth-child(5),
|
||||
.table td:nth-child(5) {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.table th:nth-child(4),
|
||||
.table td:nth-child(4) {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
.sortButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sortIcons {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.55rem;
|
||||
line-height: 0.7;
|
||||
}
|
||||
|
||||
.sortIcons span {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortIcons .sortActive {
|
||||
opacity: 1;
|
||||
color: var(--mm-color-denim);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sortIcons .sortActive {
|
||||
color: var(--mm-denim-200);
|
||||
}
|
||||
|
||||
.issueId {
|
||||
display: block;
|
||||
font-family: var(--mm-font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cve {
|
||||
display: inline-block;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.versionList {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.versionList li + li {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.empty {
|
||||
color: var(--mm-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 1.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badgeCritical {
|
||||
background: #fbeded;
|
||||
color: #8b1f1f;
|
||||
}
|
||||
|
||||
.badgeHigh {
|
||||
background: #fdecef;
|
||||
color: #c8102e;
|
||||
}
|
||||
|
||||
.badgeMedium {
|
||||
background: var(--mm-marigold-300);
|
||||
color: var(--mm-color-black);
|
||||
border-color: var(--mm-marigold-500);
|
||||
}
|
||||
|
||||
.badgeLow {
|
||||
background: #eaf5ee;
|
||||
color: #1f5a39;
|
||||
}
|
||||
|
||||
.badgeNA {
|
||||
background: var(--mm-denim-100);
|
||||
color: var(--mm-denim-700);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeCritical {
|
||||
background: #4a1f1f;
|
||||
color: #e9a0a0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeHigh {
|
||||
background: #4e0f1b;
|
||||
color: #f4a0ab;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeMedium {
|
||||
background: #5c4a14;
|
||||
color: var(--mm-marigold-100);
|
||||
border-color: var(--mm-marigold-700);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeLow {
|
||||
background: #14442c;
|
||||
color: #7fcc9e;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeNA {
|
||||
background: var(--mm-denim-700);
|
||||
color: var(--mm-denim-200);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pagination button,
|
||||
.ellipsis {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--mm-text-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pageActive {
|
||||
border-color: var(--mm-border-strong) !important;
|
||||
color: var(--mm-color-denim);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .pageActive {
|
||||
color: var(--mm-marigold-300);
|
||||
border-color: var(--mm-denim-300) !important;
|
||||
}
|
||||
|
||||
.status,
|
||||
.error {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
background: var(--mm-bg-subtle);
|
||||
}
|
||||
|
||||
.error p {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
231
docs/site/src/components/SecurityUpdates/versions.ts
Normal file
231
docs/site/src/components/SecurityUpdates/versions.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
export type Version = [number, number, number];
|
||||
export type VersionRange = {min: Version; max: Version};
|
||||
|
||||
const INF = 999_999;
|
||||
const ZERO: Version = [0, 0, 0];
|
||||
const MAX: Version = [INF, INF, INF];
|
||||
|
||||
export function parseVersionToken(raw: string): Version | null {
|
||||
const cleaned = raw.trim().replace(/^v/i, '');
|
||||
const match = cleaned.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+|x))?/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const major = Number(match[1]);
|
||||
const minor = match[2] === undefined ? 0 : Number(match[2]);
|
||||
const patchToken = match[3];
|
||||
const patch =
|
||||
patchToken === undefined ? 0 : patchToken.toLowerCase() === 'x' ? 0 : Number(patchToken);
|
||||
return [major, minor, patch];
|
||||
}
|
||||
|
||||
export function compareVersions(a: Version, b: Version): number {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
return a[i] - b[i];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function exclusiveUpperBound(v: Version): Version {
|
||||
if (v[2] > 0) {
|
||||
return [v[0], v[1], v[2] - 1];
|
||||
}
|
||||
if (v[1] > 0) {
|
||||
return [v[0], v[1] - 1, INF];
|
||||
}
|
||||
if (v[0] > 0) {
|
||||
return [v[0] - 1, INF, INF];
|
||||
}
|
||||
return ZERO;
|
||||
}
|
||||
|
||||
function versionFromMatch(token: string, asUpperBound: boolean): Version | null {
|
||||
const parsed = parseVersionToken(token);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const cleaned = token.trim().replace(/^v/i, '');
|
||||
if (asUpperBound && /\.x$/i.test(cleaned)) {
|
||||
return [parsed[0], parsed[1], INF];
|
||||
}
|
||||
// "11" as a max means the whole 11 major line.
|
||||
if (asUpperBound && /^\d+$/.test(cleaned)) {
|
||||
return [parsed[0], INF, INF];
|
||||
}
|
||||
// "11.9" as a max means the whole 11.9 line.
|
||||
if (asUpperBound && /^\d+\.\d+$/.test(cleaned)) {
|
||||
return [parsed[0], parsed[1], INF];
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOneLine(line: string): VersionRange[] {
|
||||
const text = line.trim();
|
||||
if (!text || /^(n\/a|na)$/i.test(text) || /^exclud/i.test(text)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (/^all$/i.test(text)) {
|
||||
return [{min: ZERO, max: MAX}];
|
||||
}
|
||||
|
||||
const toMatch = text.match(
|
||||
/v?(\d+(?:\.\d+){0,2}(?:\.x)?)\s+to\s+v?(\d+(?:\.\d+){0,2}(?:\.x)?)/i,
|
||||
);
|
||||
if (toMatch) {
|
||||
const min = versionFromMatch(toMatch[1], false);
|
||||
const max = versionFromMatch(toMatch[2], true);
|
||||
if (min && max) {
|
||||
return [{min, max}];
|
||||
}
|
||||
}
|
||||
|
||||
const earlier = text.match(/v?(\d+(?:\.\d+){0,2}(?:\.x)?)\s+and earlier/i);
|
||||
if (earlier) {
|
||||
const max = versionFromMatch(earlier[1], true);
|
||||
if (max) {
|
||||
return [{min: ZERO, max}];
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('<=')) {
|
||||
const segments = text.split('<=');
|
||||
const left = (segments[0] || '').trim();
|
||||
const rightSegments = segments.slice(1).map((part) => part.trim()).filter(Boolean);
|
||||
if (rightSegments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Multiple "<=" operators: union of upper bounds (e.g. "<=7.1.9 <=7.8.4").
|
||||
if (rightSegments.length > 1) {
|
||||
return rightSegments
|
||||
.map((part) => versionFromMatch(part.split(/\s+/)[0] || '', true))
|
||||
.filter((max): max is Version => max !== null)
|
||||
.map((max) => ({min: ZERO, max}));
|
||||
}
|
||||
|
||||
const tokens = rightSegments[0].split(/\s+/).filter(Boolean);
|
||||
// Pre-2024 mashed line: "<=11.9 11.0.9 11.4.8 …" is a version list, not one open range.
|
||||
if (tokens.length > 1 && !left) {
|
||||
return tokens.flatMap((token) => {
|
||||
const min = versionFromMatch(token, false);
|
||||
const max = versionFromMatch(token, true);
|
||||
return min && max ? [{min, max}] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const max = versionFromMatch(tokens[0] || '', true);
|
||||
if (!max) {
|
||||
return [];
|
||||
}
|
||||
const branch = left.match(/(\d+)\.(\d+)\.x/i);
|
||||
if (branch) {
|
||||
return [{min: [Number(branch[1]), Number(branch[2]), 0], max}];
|
||||
}
|
||||
const leftVer = parseVersionToken(left.replace(/^.*\s/, ''));
|
||||
if (leftVer) {
|
||||
return [{min: leftVer, max}];
|
||||
}
|
||||
return [{min: ZERO, max}];
|
||||
}
|
||||
|
||||
if (text.includes('>=')) {
|
||||
const min = versionFromMatch(text.split('>=')[1] || '', false);
|
||||
if (min) {
|
||||
return [{min, max: MAX}];
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('<')) {
|
||||
const ranges: VersionRange[] = [];
|
||||
for (const match of text.matchAll(/<\s*v?(\d+(?:\.\d+){0,2}(?:\.x)?)/gi)) {
|
||||
// Exclusive bound: do not expand "7.9" to the whole 7.9 line first.
|
||||
const bound = versionFromMatch(match[1], false);
|
||||
if (bound) {
|
||||
ranges.push({min: ZERO, max: exclusiveUpperBound(bound)});
|
||||
}
|
||||
}
|
||||
if (ranges.length > 0) {
|
||||
return ranges;
|
||||
}
|
||||
}
|
||||
|
||||
const ranges: VersionRange[] = [];
|
||||
for (const match of text.matchAll(/v?(\d+\.\d+(?:\.\d+|\.x)?)/gi)) {
|
||||
const token = match[1];
|
||||
const version = parseVersionToken(token);
|
||||
if (version) {
|
||||
ranges.push({min: version, max: version});
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function highestFixVersion(fixVersions: string[]): Version | null {
|
||||
let highest: Version | null = null;
|
||||
for (const line of fixVersions) {
|
||||
for (const match of line.matchAll(/v?(\d+\.\d+(?:\.\d+)?)/gi)) {
|
||||
const parsed = parseVersionToken(match[1]);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (!highest || compareVersions(parsed, highest) > 0) {
|
||||
highest = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
function isUnbounded(range: VersionRange): boolean {
|
||||
return compareVersions(range.max, MAX) === 0;
|
||||
}
|
||||
|
||||
export function parseAffectedLines(lines: string[], fixVersions: string[] = []): VersionRange[] {
|
||||
const ranges = lines.flatMap(parseOneLine);
|
||||
const cap = highestFixVersion(fixVersions);
|
||||
if (!cap) {
|
||||
return ranges;
|
||||
}
|
||||
return ranges.map((range) => {
|
||||
if (!isUnbounded(range)) {
|
||||
return range;
|
||||
}
|
||||
return {
|
||||
min: range.min,
|
||||
max: compareVersions(cap, range.min) < 0 ? range.min : cap,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function rangesOverlap(a: VersionRange, b: VersionRange): boolean {
|
||||
return compareVersions(a.min, b.max) <= 0 && compareVersions(b.min, a.max) <= 0;
|
||||
}
|
||||
|
||||
export function rowMatchesVersionRange(
|
||||
ranges: VersionRange[],
|
||||
minFilter: Version | null,
|
||||
maxFilter: Version | null,
|
||||
): boolean {
|
||||
if (!minFilter && !maxFilter) {
|
||||
return true;
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const filter: VersionRange = {
|
||||
min: minFilter ?? ZERO,
|
||||
max: maxFilter ?? MAX,
|
||||
};
|
||||
return ranges.some((range) => rangesOverlap(range, filter));
|
||||
}
|
||||
|
||||
export function labelToVersion(label: string, bound: 'min' | 'max'): Version | null {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed || !/^v?\d+(?:\.\d+){0,2}(?:\.x)?$/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return versionFromMatch(trimmed, bound === 'max');
|
||||
}
|
||||
33
docs/site/src/components/StatStrip/index.tsx
Normal file
33
docs/site/src/components/StatStrip/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type Stat = {
|
||||
value: string | number;
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizontal strip of headline numbers — used at the top of landing
|
||||
* pages to surface the size/scope of the section in a single glance.
|
||||
*
|
||||
* Usage:
|
||||
* <StatStrip stats={[
|
||||
* {value: '549', label: 'Endpoints'},
|
||||
* {value: '38', label: 'Resource groups'},
|
||||
* {value: '5', label: 'Languages', hint: 'curl, PowerShell, Python, Node, Go'},
|
||||
* ]} />
|
||||
*/
|
||||
export default function StatStrip({stats}: {stats: Stat[]}) {
|
||||
return (
|
||||
<ul className={styles.strip}>
|
||||
{stats.map((s) => (
|
||||
<li key={s.label} className={styles.stat}>
|
||||
<span className={styles.value}>{s.value}</span>
|
||||
<span className={styles.label}>{s.label}</span>
|
||||
{s.hint && <span className={styles.hint}>{s.hint}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
48
docs/site/src/components/StatStrip/styles.module.css
Normal file
48
docs/site/src/components/StatStrip/styles.module.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.strip {
|
||||
list-style: none;
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
padding: 1.25rem 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem 2rem;
|
||||
border-top: 2px solid var(--mm-color-marigold);
|
||||
border-bottom: 1px solid var(--mm-border-subtle);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: var(--mm-font-heading);
|
||||
font-weight: 900;
|
||||
font-size: clamp(1.75rem, 2.5vw, 2.4rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--mm-color-denim);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .value {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--mm-font-heading);
|
||||
font-weight: 900;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mm-text-secondary);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: var(--mm-text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
224
docs/site/src/components/UpgradeNotesFilter/index.tsx
Normal file
224
docs/site/src/components/UpgradeNotesFilter/index.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type VersionParts = {major: number; minor: number};
|
||||
type RowRef = {row: HTMLTableRowElement; version: string};
|
||||
|
||||
function sortVersions(versions: string[]): string[] {
|
||||
return [...versions].sort((a, b) => {
|
||||
const [majorA, minorA] = a.split('.').map(Number);
|
||||
const [majorB, minorB] = b.split('.').map(Number);
|
||||
if (majorA !== majorB) {
|
||||
return majorB - majorA;
|
||||
}
|
||||
return minorB - minorA;
|
||||
});
|
||||
}
|
||||
|
||||
function parseVersion(version: string): VersionParts {
|
||||
if (version === 'all') {
|
||||
return {major: 0, minor: 0};
|
||||
}
|
||||
const [major, minor] = version.split('.').map(Number);
|
||||
return {major, minor};
|
||||
}
|
||||
|
||||
function isVersionRelevant(
|
||||
version: string,
|
||||
sourceVersion: string,
|
||||
targetVersion: string,
|
||||
): boolean {
|
||||
const rowV = parseVersion(version);
|
||||
const sourceV = parseVersion(sourceVersion);
|
||||
const targetV = parseVersion(targetVersion);
|
||||
|
||||
return (
|
||||
(sourceVersion === 'all' ||
|
||||
rowV.major > sourceV.major ||
|
||||
(rowV.major === sourceV.major && rowV.minor >= sourceV.minor)) &&
|
||||
(targetVersion === 'all' ||
|
||||
rowV.major < targetV.major ||
|
||||
(rowV.major === targetV.major && rowV.minor <= targetV.minor))
|
||||
);
|
||||
}
|
||||
|
||||
function collectRows(table: HTMLTableElement): {rows: RowRef[]; versions: string[]} {
|
||||
const rows: RowRef[] = [];
|
||||
const versions: string[] = [];
|
||||
let lastVersion: string | null = null;
|
||||
|
||||
table.querySelectorAll<HTMLTableRowElement>('tbody tr').forEach((tr) => {
|
||||
const cells = tr.querySelectorAll('td');
|
||||
if (cells.length > 1) {
|
||||
const versionText = cells[0].textContent?.trim() ?? '';
|
||||
const versionMatch = versionText.match(/^v(\d+\.\d+)$/);
|
||||
if (versionMatch) {
|
||||
const version = versionMatch[1];
|
||||
if (!versions.includes(version)) {
|
||||
versions.push(version);
|
||||
}
|
||||
lastVersion = version;
|
||||
rows.push({row: tr, version});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastVersion && cells.length >= 1) {
|
||||
rows.push({row: tr, version: lastVersion});
|
||||
}
|
||||
});
|
||||
|
||||
rows.forEach(({row, version}) => {
|
||||
row.dataset.version = version;
|
||||
});
|
||||
|
||||
return {rows, versions: sortVersions(versions)};
|
||||
}
|
||||
|
||||
function findUpgradeNotesTable(): HTMLTableElement | null {
|
||||
return document.querySelector<HTMLTableElement>('.upgrade-notes-table');
|
||||
}
|
||||
|
||||
export default function UpgradeNotesFilter() {
|
||||
const rowsRef = useRef<RowRef[]>([]);
|
||||
const [versions, setVersions] = useState<string[]>([]);
|
||||
const [sourceVersion, setSourceVersion] = useState('all');
|
||||
const [targetVersion, setTargetVersion] = useState('all');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const initializeRows = useCallback(() => {
|
||||
const table = findUpgradeNotesTable();
|
||||
if (!table || table.querySelectorAll('tbody tr').length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {rows, versions: foundVersions} = collectRows(table);
|
||||
if (rows.length === 0 || foundVersions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
rowsRef.current = rows;
|
||||
setVersions(foundVersions);
|
||||
setReady(true);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initializeRows()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (initializeRows()) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [initializeRows]);
|
||||
|
||||
const showAllRows = () => {
|
||||
rowsRef.current.forEach(({row}) => row.classList.remove('filtered'));
|
||||
};
|
||||
|
||||
const applyFilter = () => {
|
||||
setError(null);
|
||||
|
||||
if (!ready || rowsRef.current.length === 0) {
|
||||
if (!initializeRows()) {
|
||||
setError('Error: Upgrade notes table is still loading. Try again in a moment.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceVersion === 'all' && targetVersion === 'all') {
|
||||
showAllRows();
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceV = parseVersion(sourceVersion);
|
||||
const targetV = parseVersion(targetVersion);
|
||||
|
||||
if (
|
||||
targetV.major < sourceV.major ||
|
||||
(targetV.major === sourceV.major && targetV.minor < sourceV.minor)
|
||||
) {
|
||||
setError('Error: Target version must be greater than or equal to source version.');
|
||||
return;
|
||||
}
|
||||
|
||||
const versionVisibility = new Map<string, boolean>();
|
||||
versions.forEach((version) => {
|
||||
versionVisibility.set(
|
||||
version,
|
||||
isVersionRelevant(version, sourceVersion, targetVersion),
|
||||
);
|
||||
});
|
||||
|
||||
rowsRef.current.forEach(({row, version}) => {
|
||||
row.classList.toggle('filtered', !versionVisibility.get(version));
|
||||
});
|
||||
};
|
||||
|
||||
const resetFilter = () => {
|
||||
setSourceVersion('all');
|
||||
setTargetVersion('all');
|
||||
setError(null);
|
||||
showAllRows();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
<h3 className={styles.title}>Filter Upgrade Notes</h3>
|
||||
<p className={styles.description}>
|
||||
Select source and target versions to see only relevant upgrade notes
|
||||
</p>
|
||||
<div className={styles.controls}>
|
||||
<label className={styles.field} htmlFor="source-version">
|
||||
From version:
|
||||
<select
|
||||
id="source-version"
|
||||
value={sourceVersion}
|
||||
onChange={(event) => setSourceVersion(event.target.value)}
|
||||
disabled={!ready}>
|
||||
<option value="all">All versions</option>
|
||||
{versions.map((version) => (
|
||||
<option key={`source-${version}`} value={version}>
|
||||
v{version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field} htmlFor="target-version">
|
||||
To version:
|
||||
<select
|
||||
id="target-version"
|
||||
value={targetVersion}
|
||||
onChange={(event) => setTargetVersion(event.target.value)}
|
||||
disabled={!ready}>
|
||||
<option value="all">All versions</option>
|
||||
{versions.map((version) => (
|
||||
<option key={`target-${version}`} value={version}>
|
||||
v{version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.applyButton}
|
||||
onClick={applyFilter}
|
||||
disabled={!ready}>
|
||||
Apply Filter
|
||||
</button>
|
||||
<button type="button" className={styles.resetButton} onClick={resetFilter}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
{error ? <div className={styles.error}>{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
.filters {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 4px;
|
||||
background: var(--mm-bg-subtle);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-family: var(--mm-font-heading);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field select {
|
||||
min-width: 10rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 4px;
|
||||
background: var(--mm-bg-surface);
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
.applyButton,
|
||||
.resetButton {
|
||||
padding: 0.55rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.applyButton {
|
||||
background: var(--mm-denim-400);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.applyButton:hover {
|
||||
background: var(--mm-color-denim);
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
background: transparent;
|
||||
color: var(--mm-text-primary);
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
}
|
||||
|
||||
.resetButton:hover {
|
||||
background: var(--mm-bg-surface);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-left: 4px solid #e74c3c;
|
||||
border-radius: 4px;
|
||||
background: #fadbd8;
|
||||
color: #922b21;
|
||||
}
|
||||
|
||||
:global(.upgrade-notes-table tr.filtered) {
|
||||
display: none;
|
||||
}
|
||||
989
docs/site/src/css/custom.css
Normal file
989
docs/site/src/css/custom.css
Normal file
@@ -0,0 +1,989 @@
|
||||
/*
|
||||
* Mattermost docs theme overrides on top of Infima.
|
||||
* Tokens live in tokens.css. This file maps tokens to Infima vars and
|
||||
* adds the brand surface treatments (navbar density, code blocks,
|
||||
* sidebar, footer) so the site reads as part of the Mattermost product
|
||||
* family, not a default Docusaurus install.
|
||||
*/
|
||||
|
||||
/* === Map brand tokens onto Infima === */
|
||||
|
||||
:root {
|
||||
--ifm-color-primary: var(--mm-denim-500);
|
||||
--ifm-color-primary-dark: var(--mm-denim-600);
|
||||
--ifm-color-primary-darker: var(--mm-denim-700);
|
||||
--ifm-color-primary-darkest: var(--mm-denim-800);
|
||||
--ifm-color-primary-light: var(--mm-denim-400);
|
||||
--ifm-color-primary-lighter: var(--mm-denim-300);
|
||||
--ifm-color-primary-lightest: var(--mm-denim-200);
|
||||
|
||||
--ifm-font-family-base: var(--mm-font-sans);
|
||||
--ifm-font-family-monospace: var(--mm-font-mono);
|
||||
--ifm-heading-font-family: var(--mm-font-sans);
|
||||
--ifm-heading-font-weight: 700;
|
||||
--ifm-h1-font-size: 2.6rem;
|
||||
--ifm-h2-font-size: 1.85rem;
|
||||
--ifm-h3-font-size: 1.35rem;
|
||||
--ifm-line-height-base: 1.65;
|
||||
--ifm-spacing-vertical: 1.2rem;
|
||||
|
||||
--ifm-code-font-size: 90%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(30, 50, 92, 0.1);
|
||||
|
||||
--ifm-background-color: var(--mm-bg-page);
|
||||
--ifm-background-surface-color: var(--mm-bg-surface);
|
||||
--ifm-navbar-background-color: var(--mm-color-denim);
|
||||
--ifm-navbar-link-color: rgba(255, 255, 255, 0.85);
|
||||
--ifm-navbar-link-hover-color: var(--mm-color-white);
|
||||
--ifm-navbar-height: 4rem;
|
||||
--ifm-footer-background-color: var(--mm-color-denim);
|
||||
--ifm-footer-color: var(--mm-color-white);
|
||||
--ifm-footer-link-color: rgba(255,255,255,0.78);
|
||||
--ifm-footer-link-hover-color: var(--mm-color-marigold);
|
||||
--ifm-footer-title-color: var(--mm-color-white);
|
||||
|
||||
--ifm-link-color: var(--mm-link);
|
||||
--ifm-link-hover-color: var(--mm-link-hover);
|
||||
--ifm-link-hover-decoration: underline;
|
||||
|
||||
--ifm-toc-border-color: var(--mm-border-subtle);
|
||||
--ifm-hr-border-color: var(--mm-border-subtle);
|
||||
|
||||
/* Infima's own default body color isn't derived from our brand palette
|
||||
* at all — pin it to --mm-text-primary for a guaranteed, auditable
|
||||
* near-black. */
|
||||
--ifm-font-color-base: var(--mm-text-primary);
|
||||
--ifm-color-content: var(--mm-text-primary);
|
||||
--ifm-font-color-secondary: var(--mm-text-secondary);
|
||||
--ifm-color-content-secondary: var(--mm-text-secondary);
|
||||
|
||||
/* Sidebar */
|
||||
--ifm-menu-color: var(--mm-denim-700);
|
||||
--ifm-menu-color-active: var(--mm-color-denim);
|
||||
/* denim-100, not -50: the sidebar container itself now sits on
|
||||
* --mm-bg-subtle (= denim-50, see .theme-doc-sidebar-container), so the
|
||||
* active/hover highlight needs a visibly darker step or it'd disappear
|
||||
* into its own background. */
|
||||
--ifm-menu-color-background-active: var(--mm-denim-100);
|
||||
--ifm-menu-color-background-hover: var(--mm-denim-100);
|
||||
--ifm-menu-link-padding-vertical: 0.45rem;
|
||||
--ifm-menu-link-padding-horizontal: 0.85rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: var(--mm-color-sky);
|
||||
--ifm-color-primary-dark: var(--mm-denim-300);
|
||||
--ifm-color-primary-darker: var(--mm-denim-400);
|
||||
--ifm-color-primary-darkest: var(--mm-denim-500);
|
||||
--ifm-color-primary-light: var(--mm-denim-100);
|
||||
--ifm-color-primary-lighter: var(--mm-denim-50);
|
||||
--ifm-color-primary-lightest: var(--mm-color-white);
|
||||
|
||||
--ifm-background-color: var(--mm-bg-page);
|
||||
--ifm-background-surface-color: var(--mm-bg-surface);
|
||||
--docusaurus-highlighted-code-line-bg: rgba(255, 188, 31, 0.18);
|
||||
|
||||
--ifm-navbar-background-color: var(--mm-denim-800);
|
||||
--ifm-navbar-link-color: rgba(255,255,255,0.85);
|
||||
--ifm-navbar-link-hover-color: var(--mm-color-white);
|
||||
|
||||
--ifm-menu-color: rgba(255,255,255,0.78);
|
||||
--ifm-menu-color-active: var(--mm-color-white);
|
||||
--ifm-menu-color-background-active: var(--mm-denim-700);
|
||||
--ifm-menu-color-background-hover: var(--mm-denim-700);
|
||||
|
||||
/* See light-mode block above — same rationale, dark-mode near-white. */
|
||||
--ifm-font-color-base: var(--mm-text-primary);
|
||||
--ifm-color-content: var(--mm-text-primary);
|
||||
--ifm-font-color-secondary: var(--mm-text-secondary);
|
||||
--ifm-color-content-secondary: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
/* The mobile nav slide-out (.navbar-sidebar) always has a dark denim
|
||||
* background, but its menu inherits the light-mode --ifm-menu-color*
|
||||
* vars tuned for the light desktop sidebar — force the dark-mode values
|
||||
* unconditionally so mobile text stays readable in light mode too. */
|
||||
.navbar-sidebar {
|
||||
--ifm-menu-color: rgba(255,255,255,0.78);
|
||||
--ifm-menu-color-active: var(--mm-color-white);
|
||||
--ifm-menu-color-background-active: var(--mm-denim-700);
|
||||
--ifm-menu-color-background-hover: var(--mm-denim-700);
|
||||
}
|
||||
/* "Back to main menu" text color isn't covered by the vars above. */
|
||||
.navbar-sidebar__back {
|
||||
color: var(--ifm-menu-color-active);
|
||||
}
|
||||
|
||||
/* === Type rhythm === */
|
||||
|
||||
html {
|
||||
font-feature-settings: "ss01", "cv11"; /* Inter alt forms */
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--mm-font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Headlines target Trade Gothic Next Heavy proportions: tighter set
|
||||
* than Archivo Black's default. We tune letter-spacing per size and
|
||||
* apply a mild horizontal squeeze via font-stretch where the platform
|
||||
* supports it (no-op on Archivo Black, which is fixed-width).
|
||||
*
|
||||
* h1 used to be 900-weight Archivo Black, but that read as too
|
||||
* heavy/shouty for page titles at body-copy reading distance — same
|
||||
* complaint that already moved h2-h4 to this sans-at-700 treatment, so
|
||||
* h1 now shares it too (just at its own larger --ifm-h1-font-size). */
|
||||
h1,
|
||||
.markdown h2, .markdown h3, .markdown h4,
|
||||
h2, h3, h4 {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* A touch of extra tracking on bold prose keeps adjacent heavy glyphs
|
||||
* from visually merging — subtle enough not to loosen word shapes. */
|
||||
.markdown strong,
|
||||
.markdown b {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Color alone isn't a reliable "this is a link" signal (WCAG 1.4.1), but a
|
||||
* default underline on every inline link reads as visual noise in body
|
||||
* copy. Medium weight is the non-color cue instead — heavier than body
|
||||
* text, without competing with `.markdown strong`'s bold weight or adding
|
||||
* an underline (reserved for hover/focus). Scoped to prose so nav/footer/
|
||||
* sidebar links keep their own treatments. */
|
||||
.markdown a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Search results page ("See all results") — each hit's title is a plain h2
|
||||
* outside `.markdown`, so it inherits the full --ifm-h2-font-size (1.85rem),
|
||||
* as large as an in-article section header. Scoped to the wrapper class
|
||||
* Docusaurus's SearchPage puts on <html> rather than the plugin's own
|
||||
* (hashed) CSS-module class, so this survives dependency upgrades. */
|
||||
.search-page-wrapper h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
/* Section heading rhythm — a small marigold rule sits above each h2.
|
||||
* Scoped via the `mm-section` class (auto-applied below) so it doesn't
|
||||
* collide with OpenAPI plugin internal h2s (endpoint URL header,
|
||||
* Request/Response panels, tab headings, etc.) which use their own
|
||||
* layout and get squashed by our padding-top.
|
||||
*
|
||||
* The auto-apply trick: any h2 inside a doc page that ISN'T a
|
||||
* descendant of an `.openapi-*` container gets the rule. */
|
||||
.markdown h2:not([class*="openapi"]):not([class*="openapi"] *) {
|
||||
position: relative;
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.4rem;
|
||||
}
|
||||
.markdown h2:not([class*="openapi"]):not([class*="openapi"] *)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3rem;
|
||||
height: 2px;
|
||||
background: var(--mm-color-marigold);
|
||||
}
|
||||
|
||||
/* Belt-and-braces: explicitly null the rule on every OpenAPI plugin
|
||||
* heading we know the plugin emits. */
|
||||
.markdown .openapi__method-endpoint-path,
|
||||
.markdown .openapi-tabs__heading,
|
||||
.markdown .openapi-tabs__response-header,
|
||||
.markdown [class*="openapi"] h2 {
|
||||
position: static !important;
|
||||
margin-top: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
.markdown .openapi__method-endpoint-path::before,
|
||||
.markdown .openapi-tabs__heading::before,
|
||||
.markdown .openapi-tabs__response-header::before,
|
||||
.markdown [class*="openapi"] h2::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* === Navbar — solid denim with white text === */
|
||||
|
||||
.navbar {
|
||||
background-color: var(--mm-color-denim);
|
||||
border-bottom: 2px solid var(--mm-color-marigold);
|
||||
box-shadow: 0 2px 12px -8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.navbar__inner {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 clamp(1rem, 2.5vw, 1.75rem);
|
||||
}
|
||||
|
||||
.navbar__brand {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
.navbar__logo {
|
||||
height: 1.85rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.navbar__item {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.navbar__link {
|
||||
/* Body font (Inter) for legibility at small sizes — Archivo Black 900 is
|
||||
* designed for display headlines, not 12-14px nav. Brand identity is
|
||||
* preserved via uppercase + letter-spacing. */
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 0.85rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.navbar__link {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.navbar__link:hover {
|
||||
color: var(--mm-color-white);
|
||||
text-decoration: none;
|
||||
}
|
||||
.navbar__link--active,
|
||||
.navbar__link[aria-current="page"] {
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
.navbar__link--active::after,
|
||||
.navbar__link[aria-current="page"]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.85rem;
|
||||
right: 0.85rem;
|
||||
bottom: -1.2rem;
|
||||
height: 3px;
|
||||
background: var(--mm-color-marigold);
|
||||
}
|
||||
|
||||
/* GitHub link → render as outlined ghost on a denim navbar. */
|
||||
.navbar__items--right .navbar__link[href*="github.com"] {
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: var(--mm-color-white);
|
||||
border-radius: 3px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.navbar__items--right .navbar__link[href*="github.com"]:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
/* Color-mode toggle on a denim navbar — white icon, subtle hover. */
|
||||
.navbar .clean-btn {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
.navbar .clean-btn:hover {
|
||||
color: var(--mm-color-white);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* === Sidebar === */
|
||||
|
||||
/* Left nav, right TOC, and the main reading column previously all shared
|
||||
* the same page background, so nothing set the article apart as "the
|
||||
* content". Tinting just the side chrome with --mm-bg-subtle — leaving
|
||||
* the main column on --mm-bg-page/-surface — makes the reading pane read
|
||||
* as the brighter, foregrounded surface. */
|
||||
.theme-doc-sidebar-container {
|
||||
background-color: var(--mm-bg-subtle);
|
||||
border-right: 1px solid var(--mm-border-subtle) !important;
|
||||
}
|
||||
[data-theme='dark'] .theme-doc-sidebar-container {
|
||||
border-right-color: var(--mm-denim-700) !important;
|
||||
}
|
||||
|
||||
.menu {
|
||||
padding: 1.25rem 0.75rem !important;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.menu__list-item-collapsible,
|
||||
.menu__link {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.menu__link--sublist-caret::after {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.menu__caret {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.menu__list-item--collapsed .menu__list-item-collapsible {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Categories at any depth — including OpenAPI tag groups in the API
|
||||
* sidebar (users → Users, audit logs → Audit Logs) — render in title
|
||||
* case. (`::first-letter` doesn't apply to the menu link's flex layout,
|
||||
* so we use text-transform: capitalize which capitalizes the first
|
||||
* letter of every word.)
|
||||
*
|
||||
* Font: Archivo Black's 900-weight display face reads fine for a short,
|
||||
* single word/acronym at large size (e.g. an "IME" page title), but at
|
||||
* this small size (0.82rem) its heavy strokes and tight kerning crowd
|
||||
* multi-word labels together — use the body sans face at bold instead. */
|
||||
.menu .menu__list-item-collapsible > .menu__link,
|
||||
.menu > .menu__list > .menu__list-item > .menu__link {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: capitalize;
|
||||
font-size: 0.82rem;
|
||||
color: var(--mm-text-secondary);
|
||||
padding-top: 0.55rem;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* Category headers hardcode --mm-text-secondary above, so they don't
|
||||
* pick up the .navbar-sidebar menu-color override — fix separately. */
|
||||
.navbar-sidebar .menu .menu__list-item-collapsible > .menu__link,
|
||||
.navbar-sidebar .menu > .menu__list > .menu__list-item > .menu__link {
|
||||
color: var(--ifm-menu-color);
|
||||
}
|
||||
|
||||
/* Endpoint leaves keep their natural case (e.g. "Login to Mattermost server"). */
|
||||
.menu .menu__link.api-method {
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
font-size: 0.85rem;
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
/* === Code === */
|
||||
|
||||
/* `:not(pre) > code`, not bare `code`: a code block's own `<code>` (inside
|
||||
* `<pre>`) is also just a `code` element, and this rule's specificity
|
||||
* ([data-theme='dark'] + tag) beats Infima's `pre code { background:
|
||||
* transparent }` (two tags only). Left unscoped, this inline-code
|
||||
* background painted over almost the entire visible area of every
|
||||
* dark-mode code block. */
|
||||
:not(pre) > code {
|
||||
background-color: var(--mm-denim-50);
|
||||
color: var(--mm-text-primary);
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 3px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-size: 0.88em;
|
||||
/* Infima's `code` selector has no line-height of its own, so an inline
|
||||
* code span inherits the paragraph's 1.65 --ifm-line-height-base,
|
||||
* inflating the whole line's height wherever one shows up mid-sentence.
|
||||
* Multi-line fences are unaffected — `pre code` has higher specificity
|
||||
* and `.theme-code-block .prism-code` sets its own line-height. */
|
||||
line-height: 1.2;
|
||||
}
|
||||
/* Same dark-on-dark collision as blockquote/callout/code-block: denim-800
|
||||
* (#0C1424) and denim-700 (#121E37) are both within ~1:1 contrast of the
|
||||
* page background (#0E1525), so inline code like `ClusterEncryptionKey`
|
||||
* had no visible chip around it in dark mode. --mm-bg-surface + denim-400
|
||||
* match the fill/border already used for code blocks elsewhere in this
|
||||
* file, for a consistent "raised surface" look across all code UI. */
|
||||
[data-theme='dark'] :not(pre) > code {
|
||||
background-color: var(--mm-bg-surface);
|
||||
border-color: var(--mm-denim-400);
|
||||
}
|
||||
|
||||
/* === Admonitions / callouts ===
|
||||
* Dark-mode callout text/label/icon were nearly the same color as the
|
||||
* background across all five admonition types (note/tip/info/warning/
|
||||
* danger) — Infima's default alert coloring was never wired to our brand
|
||||
* tokens. Pin explicit, contrast-checked colors instead. Left border/
|
||||
* accent-bar colors (--ifm-alert-border-color) are intentionally left
|
||||
* untouched — a parallel "remove yellow left border" workstream reworks
|
||||
* those on the same selectors.
|
||||
*
|
||||
* Heading label and icon are set explicitly (not just via the shared
|
||||
* --ifm-alert-foreground-color below) since the label was the most
|
||||
* visibly low-contrast part, in case a theme upgrade changes the
|
||||
* inheritance. */
|
||||
[data-theme='dark'] .alert {
|
||||
--ifm-alert-foreground-color: var(--mm-callout-text);
|
||||
}
|
||||
[data-theme='dark'] .alert__heading,
|
||||
[data-theme='dark'] .alert__icon svg {
|
||||
color: var(--mm-callout-text);
|
||||
fill: var(--mm-callout-text);
|
||||
}
|
||||
/* denim-700 (the previous note/info background) is ~1.1:1 from the page
|
||||
* — same navy hue family, no visible surface. success/warning/danger
|
||||
* don't have this problem since green/amber/red are a different hue
|
||||
* family from the page. Fix: denim-500 (brand primary, ~4x page
|
||||
* luminance) as the fill, plus a border (separate from the reserved
|
||||
* --ifm-alert-border-color/left accent bar) for a crisp edge. */
|
||||
[data-theme='dark'] .alert--secondary,
|
||||
[data-theme='dark'] .alert--info {
|
||||
--ifm-alert-background-color: var(--mm-denim-500);
|
||||
border-top: 1px solid var(--mm-denim-300);
|
||||
border-right: 1px solid var(--mm-denim-300);
|
||||
border-bottom: 1px solid var(--mm-denim-300);
|
||||
}
|
||||
[data-theme='dark'] .alert--success {
|
||||
--ifm-alert-background-color: #14442c;
|
||||
}
|
||||
[data-theme='dark'] .alert--warning {
|
||||
--ifm-alert-background-color: #4a3a10;
|
||||
}
|
||||
[data-theme='dark'] .alert--danger {
|
||||
--ifm-alert-background-color: #4a1f1f;
|
||||
}
|
||||
|
||||
.theme-code-block {
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 4px;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* --mm-denim-700 (#121E37) is only ~1.1:1 contrast from the page
|
||||
* background (#0E1525) — same dark-navy hue family, no visible edge.
|
||||
* denim-400 is a real jump in lightness, giving the block a border that
|
||||
* actually reads as a boundary. */
|
||||
[data-theme='dark'] .theme-code-block {
|
||||
border-color: var(--mm-denim-400);
|
||||
}
|
||||
|
||||
/* Light mode uses the "github" Prism theme (near-white bg) — --mm-denim-50
|
||||
* keeps it in the brand palette without breaking github's token contrast.
|
||||
*
|
||||
* Dark mode uses "dracula" (docusaurus.config.ts), whose token colors are
|
||||
* tuned against its own #282a36 background — don't hardcode a different
|
||||
* background here, or token colors go muddy/low-contrast. But dracula's own
|
||||
* #282a36 is only ~1.3:1 from our page bg (#0E1525), i.e. visually the same,
|
||||
* so it still needs lightening. #52607A (~2.9:1) is that lighter fill;
|
||||
* denim-400 below gives it a real border. Needed to go further than the
|
||||
* contrast ratio alone suggests — low-luminance colors require a bigger
|
||||
* jump than the formula implies to look distinct to the eye. */
|
||||
.theme-code-block .prism-code {
|
||||
font-size: 0.85rem;
|
||||
background: var(--mm-denim-50) !important;
|
||||
}
|
||||
[data-theme='dark'] .theme-code-block .prism-code {
|
||||
background: #52607A !important;
|
||||
}
|
||||
|
||||
/* Lightening the dark-mode code block fill above (#52607A) leaves
|
||||
* dracula's comment token — already its dimmest/de-emphasized color
|
||||
* (#6272a4) — at only ~1.35:1 contrast against it, i.e. nearly invisible.
|
||||
* #D3DBF2 restores ~4.6:1 (WCAG AA) while staying a muted blue-lavender,
|
||||
* distinguishable from the brighter default token colors but still legible.
|
||||
* !important is required: prism-react-renderer sets each token's color via
|
||||
* an inline `style` attribute, which otherwise beats this class selector. */
|
||||
[data-theme='dark'] .theme-code-block .prism-code .token.comment {
|
||||
color: #D3DBF2 !important;
|
||||
}
|
||||
|
||||
/* Copy button — denim border, marigold on hover */
|
||||
.clean-btn[class*="copyButton"] {
|
||||
border: 1px solid var(--mm-border-subtle) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
.clean-btn[class*="copyButton"]:hover {
|
||||
background: var(--mm-color-marigold) !important;
|
||||
color: var(--mm-color-black) !important;
|
||||
border-color: var(--mm-color-marigold) !important;
|
||||
}
|
||||
|
||||
/* === Tables === */
|
||||
|
||||
table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
table thead {
|
||||
background: var(--mm-color-denim);
|
||||
}
|
||||
/* Same problem as the TOC category labels above — multi-word headers
|
||||
* like "Feature category" at 0.78rem crowd together in Archivo Black.
|
||||
* Same fix: base sans face at bold weight. */
|
||||
table thead th {
|
||||
color: var(--mm-color-white);
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
border: none;
|
||||
}
|
||||
table tbody td {
|
||||
border-color: var(--mm-border-subtle);
|
||||
padding: 0.7rem 1rem;
|
||||
}
|
||||
table tbody tr:nth-child(even) {
|
||||
background: var(--mm-denim-50);
|
||||
}
|
||||
/* Same collision as blockquote/callout/code — denim-800 zebra striping
|
||||
* was indistinguishable from the page background in dark mode. */
|
||||
[data-theme='dark'] table tbody tr:nth-child(even) {
|
||||
background: var(--mm-bg-surface);
|
||||
}
|
||||
|
||||
/* === Blockquote ===
|
||||
* Neutral denim border + subtle tint — marigold is reserved for CTAs and
|
||||
* interactive accents (see tokens.css), not an unconditional bar on every
|
||||
* quoted paragraph. */
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--mm-border-strong);
|
||||
background: var(--mm-bg-subtle);
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
/* --mm-bg-subtle in dark mode is denim-800 (#0C1424), effectively the same
|
||||
* as the page background — same collision already fixed for callouts and
|
||||
* code blocks. The marigold left border already reads fine; only the
|
||||
* fill needed the surface-level bump. */
|
||||
[data-theme='dark'] blockquote {
|
||||
background: var(--mm-bg-surface);
|
||||
}
|
||||
|
||||
/* === Content images ===
|
||||
* Screenshots/diagrams are committed at capture resolution (up to 1024px,
|
||||
* some portrait) with no cap beyond Infima's 100%-of-column default, so
|
||||
* they rendered at full source size. Cap both dimensions and let the
|
||||
* browser scale proportionally against whichever binds first.
|
||||
*
|
||||
* `:not([class])` scopes this to authored MDX images, leaving
|
||||
* component-rendered images (always CSS-module-classed) alone. An author
|
||||
* can opt out with an explicit `width` or `height` attribute. */
|
||||
.markdown img:not([class]):not([width]):not([height]) {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: min(100%, 720px);
|
||||
max-height: 640px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* === Inline UI icons (product menu, checkmarks, keyboard glyphs, etc.) ===
|
||||
* `<img className="theme-icon">` is used throughout the docs to inline small
|
||||
* UI icons (e.g. the product-switcher waffle icon) next to text. Nothing
|
||||
* previously styled this class, so icons rendered at their raw SVG size
|
||||
* (baseline-aligned, often 24px) and — since they're flat single-color
|
||||
* PNGs/SVGs baked black with no `fill` attribute an <img> could recolor —
|
||||
* were nearly invisible against a dark background in dark mode. */
|
||||
.theme-icon {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
vertical-align: text-bottom;
|
||||
margin: 0 0.05em;
|
||||
}
|
||||
[data-theme='dark'] .theme-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
/* A few icons carry their own brand color (green checkmark, colored
|
||||
* presence dots/badges) rather than being flat black glyphs — inverting
|
||||
* those would shift their hue instead of fixing contrast, so exempt them. */
|
||||
[data-theme='dark'] .theme-icon[src*="checkmark.svg"],
|
||||
[data-theme='dark'] .theme-icon[src*="Ack-Button"],
|
||||
[data-theme='dark'] .theme-icon[src$=".png"] {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--ifm-footer-background-color);
|
||||
background-image:
|
||||
linear-gradient(0deg, rgba(12, 20, 36, 0.55), rgba(12, 20, 36, 0.55)),
|
||||
url('/img/patterns/hex-camo-denim.svg');
|
||||
background-size: cover, 108px 125px;
|
||||
background-repeat: no-repeat, repeat;
|
||||
color: var(--ifm-footer-color);
|
||||
padding: 3.5rem 1rem 2.5rem;
|
||||
}
|
||||
/* Footer column titles ("Docs", "Community", "More") stay on the heading
|
||||
* face: short, single words are the case Archivo Black handles fine —
|
||||
* it's small multi-word labels (TOC categories, table headers above)
|
||||
* that crowd together at this weight. */
|
||||
.footer__title {
|
||||
color: var(--ifm-footer-title-color);
|
||||
font-family: var(--mm-font-heading);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.footer__copyright {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.18);
|
||||
font-size: 0.82rem;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
/* === Doc page chrome === */
|
||||
|
||||
/* Docusaurus's default `.theme-doc-markdown` is fluid; we don't constrain
|
||||
* it. The OpenAPI plugin's 2-column layout (request panel on the right)
|
||||
* needs the full width of the article column. */
|
||||
.theme-doc-markdown {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.theme-doc-markdown h1:first-child {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 2.4rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* Widen the docs main container so the sidebar isn't pushed to the edge
|
||||
* with a huge gap before the content. Default Infima container is too
|
||||
* conservative for API pages with right panels. */
|
||||
.docMainContainer_TBSr,
|
||||
[class*='docMainContainer'] {
|
||||
max-width: none !important;
|
||||
/* Sits in an outer flex row alongside the left sidebar; without
|
||||
* min-width: 0 its default `min-width: auto` lets wide content (long
|
||||
* code block lines, etc.) force it — and the whole row — wider than
|
||||
* the viewport instead of scrolling within its own content. */
|
||||
min-width: 0;
|
||||
}
|
||||
.docMainContainer_TBSr > .container,
|
||||
[class*='docMainContainer'] > .container {
|
||||
max-width: 1600px;
|
||||
padding-left: clamp(1rem, 2vw, 2rem);
|
||||
padding-right: clamp(1rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
/* theme-classic's DocItem/Layout only caps the article column at 75% via
|
||||
* its own `docItemCol` class when a TOC renders alongside it — pages with
|
||||
* `hide_table_of_contents: true` (e.g. the homepage) get a lone full-width
|
||||
* `.col` instead, so prose reads much wider than on every other doc page.
|
||||
* Recreate the same 75% cap here, keyed off the `<article>` that's always
|
||||
* a descendant of the doc content column, regardless of TOC presence. */
|
||||
@media (min-width: 997px) {
|
||||
.docMainContainer_TBSr .col:has(> div > article),
|
||||
[class*='docMainContainer'] .col:has(> div > article) {
|
||||
max-width: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Without max-width, the doc content flex column no longer has an upper
|
||||
* bound to shrink against, so a wide code block (long lines, e.g. inline
|
||||
* YAML comments) inflates its own intrinsic min-content size and drags the
|
||||
* whole row — including the TOC column — wider than the viewport, instead
|
||||
* of just scrolling horizontally within the code block itself. `min-width:
|
||||
* 0` restores the standard flexbox shrink behavior so `overflow-x: auto` on
|
||||
* code blocks can do its job. */
|
||||
.docMainContainer_TBSr > .container > .row > [class*='col'],
|
||||
[class*='docMainContainer'] > .container > .row > [class*='col'] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* TOC right column (per-page) — same side-panel treatment as the left
|
||||
* sidebar, see comment above `.theme-doc-sidebar-container`. */
|
||||
.table-of-contents {
|
||||
background-color: var(--mm-bg-subtle);
|
||||
border-left: 1px solid var(--mm-border-subtle);
|
||||
padding: 0.4rem 0.75rem 0.4rem 1rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
/* --mm-color-denim is a fixed brand navy, not a semantic/theme-aware
|
||||
* token — it never adapts for dark mode. Against the dark page background
|
||||
* that's only ~1.45:1 contrast, i.e. the active TOC item nearly vanishes.
|
||||
* --ifm-color-primary is already the theme-aware equivalent (denim in
|
||||
* light mode, --mm-color-sky in dark, ~12:1 against the dark page). */
|
||||
.table-of-contents__link--active,
|
||||
.table-of-contents__link:hover {
|
||||
color: var(--ifm-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === Marigold CTA pill (used in MDX content) === */
|
||||
|
||||
.mm-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--mm-color-marigold);
|
||||
color: var(--mm-color-black);
|
||||
font-family: var(--mm-font-heading);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.mm-cta:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 16px -8px rgba(0, 0, 0, 0.25);
|
||||
color: var(--mm-color-black);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Tables: keep native table layout so header and body columns stay aligned.
|
||||
* Wide tables (e.g. upgrade notes) use a dedicated wrapper class instead. */
|
||||
.markdown table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.upgrade-notes-table-wrapper {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table tbody {
|
||||
display: table-row-group;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table thead th {
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-family: var(--mm-font-sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table th,
|
||||
.markdown table.upgrade-notes-table td {
|
||||
vertical-align: top;
|
||||
padding: 0.5rem 0.65rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table th:first-child,
|
||||
.markdown table.upgrade-notes-table td:first-child {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table th:last-child,
|
||||
.markdown table.upgrade-notes-table td:last-child {
|
||||
width: 75%;
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table td:first-child:not(:last-child) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table tbody tr:nth-child(even) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table tbody td {
|
||||
border-bottom: 1px solid var(--mm-border-subtle);
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table pre,
|
||||
.markdown table.upgrade-notes-table .sourceCode {
|
||||
max-width: 100%;
|
||||
margin: 0.5rem 0 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown table.upgrade-notes-table pre,
|
||||
.markdown table.upgrade-notes-table pre code,
|
||||
.markdown table.upgrade-notes-table code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* === Mobile / responsive === */
|
||||
|
||||
@media (max-width: 996px) {
|
||||
|
||||
/* Tighter heading scale on small screens. */
|
||||
.markdown h1 { font-size: 1.85rem; line-height: 1.15; }
|
||||
.markdown h2 { font-size: 1.4rem; }
|
||||
.markdown h3 { font-size: 1.15rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Navbar: tighter logo + items. */
|
||||
.navbar__inner { padding: 0 0.75rem; }
|
||||
.navbar__logo { height: 1.5rem; }
|
||||
.navbar__link { padding: 0 0.5rem; font-size: 0.72rem; }
|
||||
|
||||
/* OpenAPI right-panel (Authorization, Request, Response) — stacks
|
||||
* below the content on phones instead of squeezing in a 2nd column. */
|
||||
.openapi-right-panel__container {
|
||||
width: 100% !important;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.openapi-left-panel__container {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Footer: looser stacking, less padding. */
|
||||
.footer { padding: 2.5rem 1rem 1.75rem; }
|
||||
|
||||
/* Code blocks: smaller font for phones so lines don't break too eagerly. */
|
||||
.theme-code-block .prism-code { font-size: 0.78rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* Hide the marigold accent on the navbar at very narrow widths
|
||||
* where the gradient distracts from the compact nav. */
|
||||
.navbar::after { display: none; }
|
||||
|
||||
/* Hero: even tighter on small phones. */
|
||||
.markdown h1 { font-size: 1.6rem; }
|
||||
}
|
||||
|
||||
/* === Section divider used between Hero and content === */
|
||||
|
||||
.mm-section-rule {
|
||||
height: 0;
|
||||
border: none;
|
||||
border-top: 2px solid var(--mm-color-marigold);
|
||||
width: 3rem;
|
||||
margin: 2.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
/* === Release policy Gantt chart (scoped — no global mermaid overrides) === */
|
||||
|
||||
.release-policy-gantt {
|
||||
margin: 1.25rem 0;
|
||||
/* On narrow viewports, scroll horizontally instead of shrinking the
|
||||
* chart below a legible size. */
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.release-policy-gantt .docusaurus-mermaid-container {
|
||||
margin: 0;
|
||||
background: var(--mm-color-white);
|
||||
border: 2px solid #0745a1;
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container {
|
||||
background: #1e1e1e;
|
||||
border-color: #555555;
|
||||
}
|
||||
|
||||
/* Hide mermaid's semi-transparent gantt row backgrounds (pink tint in light mode) */
|
||||
.release-policy-gantt .docusaurus-mermaid-container svg rect.section,
|
||||
.release-policy-gantt .docusaurus-mermaid-container svg rect.grid-panel {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.release-policy-gantt .docusaurus-mermaid-container svg {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.release-policy-gantt .docusaurus-mermaid-container svg line {
|
||||
stroke: #0745a1;
|
||||
}
|
||||
|
||||
.release-policy-gantt .docusaurus-mermaid-container svg rect.task {
|
||||
fill: #0984e3 !important;
|
||||
stroke: #2d3436 !important;
|
||||
stroke-width: 2px !important;
|
||||
}
|
||||
|
||||
.release-policy-gantt .docusaurus-mermaid-container svg rect[class*='crit'] {
|
||||
fill: #d63031 !important;
|
||||
stroke: #2d3436 !important;
|
||||
stroke-width: 2px !important;
|
||||
}
|
||||
|
||||
.release-policy-gantt .docusaurus-mermaid-container .taskText {
|
||||
fill: #ffffff !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
.release-policy-gantt .docusaurus-mermaid-container .taskTextOutsideLeft,
|
||||
.release-policy-gantt .docusaurus-mermaid-container .taskTextOutsideRight {
|
||||
fill: #2d3436 !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
.release-policy-gantt .docusaurus-mermaid-container .sectionTitle,
|
||||
.release-policy-gantt .docusaurus-mermaid-container text[class*='tick'],
|
||||
.release-policy-gantt .docusaurus-mermaid-container text[class*='label'] {
|
||||
fill: #2d3436 !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container svg rect.task {
|
||||
stroke: #ffffff !important;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container svg rect[class*='crit'] {
|
||||
stroke: #ffffff !important;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container text {
|
||||
fill: #ffffff !important;
|
||||
}
|
||||
|
||||
.release-policy-gantt .docusaurus-mermaid-container > svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* (Marigold strip is now the navbar's own border-bottom.) */
|
||||
|
||||
/* === Pages with a Hero hide breadcrumbs + prev/next pagination ===
|
||||
* Hero is already a strong page-opener; the breadcrumbs ("Home >
|
||||
* Product Overview > Overview") read as redundant clutter. */
|
||||
|
||||
.theme-doc-markdown:has(.mm-hero) ~ nav.pagination-nav,
|
||||
nav.pagination-nav:has(+ .theme-doc-markdown .mm-hero) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
article:has(.mm-hero) > .theme-doc-breadcrumbs,
|
||||
article:has(.mm-hero) > nav[aria-label*="readcrumb"],
|
||||
.theme-doc-markdown:has(.mm-hero) > div:first-child:has(nav[aria-label*="readcrumb"]),
|
||||
nav[aria-label*="readcrumb"]:has(~ .theme-doc-markdown .mm-hero) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Pages with a Hero: drop the article's top-padding so the hero band
|
||||
* sits flush under the navbar. Hero and body content both fill the
|
||||
* article column naturally (no width override needed). */
|
||||
.theme-doc-markdown:has(.mm-hero) {
|
||||
padding-top: 0;
|
||||
}
|
||||
99
docs/site/src/css/tokens.css
Normal file
99
docs/site/src/css/tokens.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Mattermost brand design tokens.
|
||||
* Source: Mattermost-Brand-Guidelines-2025.
|
||||
* See PLAN.md §3.2 for the full token spec and rationale.
|
||||
*
|
||||
* Accessibility constraint (per brand guidelines): use only black or white
|
||||
* text on the four primary colors — these combinations are pre-approved
|
||||
* for contrast.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* === Primary palette === */
|
||||
--mm-color-denim: #1E325C; /* primary brand, dominant */
|
||||
--mm-color-marigold: #FFBC1F; /* accent — CTAs only, used sparingly */
|
||||
--mm-color-white: #FFFFFF;
|
||||
--mm-color-black: #1B1D22;
|
||||
|
||||
/* === Secondary palette (sparingly, never overpower) === */
|
||||
--mm-color-sand: #CCC4AE;
|
||||
--mm-color-bark: #604F3D;
|
||||
--mm-color-sky: #C5D2EC;
|
||||
|
||||
/* === Denim shade ramp (derived for surfaces, borders, focus rings) === */
|
||||
--mm-denim-50: #EEF1F8;
|
||||
--mm-denim-100: #D6DDEC;
|
||||
--mm-denim-200: #ADBBD8;
|
||||
--mm-denim-300: #8499C5;
|
||||
--mm-denim-400: #5B77B1;
|
||||
--mm-denim-500: #1E325C; /* = denim, brand primary */
|
||||
--mm-denim-600: #182849;
|
||||
--mm-denim-700: #121E37;
|
||||
--mm-denim-800: #0C1424;
|
||||
--mm-denim-900: #060A12;
|
||||
|
||||
/* === Marigold shade ramp === */
|
||||
--mm-marigold-100: #FFEFC8;
|
||||
--mm-marigold-300: #FFD672;
|
||||
--mm-marigold-500: #FFBC1F; /* = marigold, brand accent */
|
||||
--mm-marigold-700: #B27F00;
|
||||
|
||||
/* === Typography ===
|
||||
* Trade Gothic Next is licensed via Adobe Fonts and not embedded yet
|
||||
* (open item — see PLAN.md §12). Until licensing is confirmed, body uses
|
||||
* Inter and headlines use Archivo Black, which is the closest free match
|
||||
* for Trade Gothic Heavy's geometric, ultra-bold display feel. */
|
||||
--mm-font-sans: "Inter", "Trade Gothic Next", -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "Helvetica Neue", system-ui, sans-serif;
|
||||
--mm-font-heading: "Archivo Black", "Trade Gothic Next Heavy",
|
||||
"Helvetica Neue", "Impact", system-ui, sans-serif;
|
||||
--mm-font-mono: "JetBrains Mono", "SF Mono", "Menlo", "Consolas",
|
||||
"Liberation Mono", monospace;
|
||||
|
||||
/* === Semantic surfaces (light) === */
|
||||
--mm-bg-page: var(--mm-color-white);
|
||||
--mm-bg-surface: var(--mm-color-white);
|
||||
--mm-bg-inverse: var(--mm-color-denim);
|
||||
--mm-bg-subtle: var(--mm-denim-50);
|
||||
--mm-text-primary: var(--mm-color-black);
|
||||
--mm-text-secondary: var(--mm-denim-600);
|
||||
--mm-text-inverse: var(--mm-color-white);
|
||||
--mm-text-on-accent: var(--mm-color-black); /* black text on marigold */
|
||||
--mm-border-subtle: var(--mm-denim-100);
|
||||
--mm-border-strong: var(--mm-denim-300);
|
||||
/* Not --mm-color-denim (#1E325C): it clears WCAG AA against white
|
||||
* (12.6:1) but sits only ~1.5:1 from --mm-text-primary, so links and
|
||||
* body copy read as the same shade of "dark". This blue keeps 6.5:1+
|
||||
* against the page while staying clearly apart from body text. */
|
||||
--mm-link: #3D5D96;
|
||||
--mm-link-hover: var(--mm-denim-700);
|
||||
--mm-accent: var(--mm-color-marigold);
|
||||
--mm-focus-ring: var(--mm-marigold-500);
|
||||
}
|
||||
|
||||
/* === Dark mode (data-theme=dark applied by Docusaurus) === */
|
||||
[data-theme='dark'] {
|
||||
--mm-bg-page: #0E1525;
|
||||
--mm-bg-surface: #14213D;
|
||||
--mm-bg-inverse: var(--mm-color-denim);
|
||||
--mm-bg-subtle: var(--mm-denim-800);
|
||||
--mm-text-primary: var(--mm-color-white);
|
||||
--mm-text-secondary: var(--mm-denim-200);
|
||||
--mm-text-inverse: var(--mm-color-black);
|
||||
--mm-text-on-accent: var(--mm-color-black);
|
||||
--mm-border-subtle: var(--mm-denim-700);
|
||||
--mm-border-strong: var(--mm-denim-500);
|
||||
/* Not --mm-color-sky (#C5D2EC): passes AA against the page (~12:1),
|
||||
* but sits only ~1.5:1 from white body text, so links disappear into
|
||||
* surrounding copy. This blue keeps ~6.4:1 against the page while
|
||||
* reading clearly darker than white body text. */
|
||||
--mm-link: #5B9BF0;
|
||||
--mm-link-hover: var(--mm-color-white);
|
||||
--mm-accent: var(--mm-color-marigold);
|
||||
/* Infima's default secondary-alert text/background pairing isn't wired
|
||||
* to our palette — pin explicit, contrast-checked brand colors instead.
|
||||
* Left border/accent-bar colors are deliberately left alone — a
|
||||
* parallel "remove yellow left border" workstream touches those. */
|
||||
--mm-callout-text: var(--mm-denim-50);
|
||||
--mm-callout-note-bg: var(--mm-denim-700);
|
||||
}
|
||||
61
docs/site/src/theme/MDXComponents.tsx
Normal file
61
docs/site/src/theme/MDXComponents.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// Globally-available MDX components. Authors can write <Note>...</Note>
|
||||
// in any .mdx file without an import.
|
||||
|
||||
import MDXComponents from '@theme-original/MDXComponents';
|
||||
import {Note, Tip, Important, Attention, Warning, Security} from '@site/src/components/Callout';
|
||||
import PlanBadge from '@site/src/components/PlanBadge';
|
||||
import PlanAvailability from '@site/src/components/PlanAvailability';
|
||||
import EditionAvailability from '@site/src/components/EditionAvailability';
|
||||
import DeploymentAvailability from '@site/src/components/DeploymentAvailability';
|
||||
import AttestationStatus from '@site/src/components/AttestationStatus';
|
||||
import DeploymentOnly from '@site/src/components/DeploymentOnly';
|
||||
import IMEDiagram from '@site/src/components/IMEDiagram';
|
||||
import DeploymentArchitectureBuilder from '@site/src/components/DeploymentArchitectureBuilder';
|
||||
import CompassIcon from '@site/src/components/CompassIcon';
|
||||
import Hero from '@site/src/components/Hero';
|
||||
import Eyebrow from '@site/src/components/Eyebrow';
|
||||
import StatStrip from '@site/src/components/StatStrip';
|
||||
import MethodLegend from '@site/src/components/MethodLegend';
|
||||
import CardGrid from '@site/src/components/CardGrid';
|
||||
import UpgradeNotesFilter from '@site/src/components/UpgradeNotesFilter';
|
||||
import SecurityUpdates from '@site/src/components/SecurityUpdates';
|
||||
import PluginGoDocs from '@site/src/components/PluginGoDocs';
|
||||
import PluginGoExample from '@site/src/components/PluginGoExample';
|
||||
import PluginJsDocs from '@site/src/components/PluginJsDocs';
|
||||
import PluginManifestDocs from '@site/src/components/PluginManifestDocs';
|
||||
// Globally available so migrated developer docs (Hugo `tabs` shortcode)
|
||||
// can use them without imports.
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
export default {
|
||||
...MDXComponents,
|
||||
Note,
|
||||
Tip,
|
||||
Important,
|
||||
Attention,
|
||||
Warning,
|
||||
Security,
|
||||
PlanBadge,
|
||||
PlanAvailability,
|
||||
EditionAvailability,
|
||||
DeploymentAvailability,
|
||||
AttestationStatus,
|
||||
DeploymentOnly,
|
||||
IMEDiagram,
|
||||
DeploymentArchitectureBuilder,
|
||||
CompassIcon,
|
||||
Hero,
|
||||
Eyebrow,
|
||||
StatStrip,
|
||||
MethodLegend,
|
||||
CardGrid,
|
||||
UpgradeNotesFilter,
|
||||
SecurityUpdates,
|
||||
PluginGoDocs,
|
||||
PluginGoExample,
|
||||
PluginJsDocs,
|
||||
PluginManifestDocs,
|
||||
Tabs,
|
||||
TabItem,
|
||||
};
|
||||
623
docs/site/src/theme/SearchPage/index.tsx
Normal file
623
docs/site/src/theme/SearchPage/index.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
/* eslint-disable jsx-a11y/no-autofocus */
|
||||
|
||||
// Ejected from @docusaurus/theme-search-algolia@3.10.1 (`SearchPage`).
|
||||
//
|
||||
// Upstream never forwards `themeConfig.algolia.searchParameters` to this
|
||||
// component — it destructures only {appId, apiKey, indexName,
|
||||
// contextualSearch} and builds the query helper from a hardcoded parameter
|
||||
// set. The DocSearch modal does honour `searchParameters`, so the two search
|
||||
// surfaces returned differently ordered results for the same query. There is
|
||||
// no config option to fix this, and Algolia Rules aren't available on our
|
||||
// plan, so swizzling is the only route.
|
||||
//
|
||||
// The ONLY delta from upstream is the three edits marked "Mattermost delta"
|
||||
// below: `searchParameters` added to the `useAlgoliaThemeConfig()`
|
||||
// destructure, `...searchParameters` spread last into the
|
||||
// `algoliaSearchHelper` options, and the `PlainSearchParameters` type import
|
||||
// the spread's cast needs. Everything else is verbatim upstream, including
|
||||
// the swizzle-stripped MIT license header. Filter values and scores are
|
||||
// deliberately not duplicated here — docusaurus.config.ts stays the single
|
||||
// source of truth so the modal and this page cannot drift apart again.
|
||||
//
|
||||
// On a Docusaurus upgrade, re-eject from the new version and re-apply those
|
||||
// three edits rather than diffing this whole file:
|
||||
// npm run swizzle @docusaurus/theme-search-algolia SearchPage -- \
|
||||
// --eject --danger --typescript
|
||||
// (the theme is only registered when ALGOLIA_APP_ID and
|
||||
// ALGOLIA_SEARCH_API_KEY are set, so export placeholders first). Drop this
|
||||
// file entirely once upstream passes `searchParameters` through.
|
||||
|
||||
import React, {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import algoliaSearchHelper, {
|
||||
type PlainSearchParameters,
|
||||
} from 'algoliasearch-helper';
|
||||
import {liteClient} from 'algoliasearch/lite';
|
||||
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import {useAllDocsData} from '@docusaurus/plugin-content-docs/client';
|
||||
import {
|
||||
HtmlClassNameProvider,
|
||||
PageMetadata,
|
||||
useEvent,
|
||||
usePluralForm,
|
||||
useSearchQueryString,
|
||||
} from '@docusaurus/theme-common';
|
||||
import Translate, {translate} from '@docusaurus/Translate';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import {
|
||||
useAlgoliaThemeConfig,
|
||||
useSearchResultUrlProcessor,
|
||||
} from '@docusaurus/theme-search-algolia/client';
|
||||
import Layout from '@theme/Layout';
|
||||
import Heading from '@theme/Heading';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
// Very simple pluralization: probably good enough for now
|
||||
function useDocumentsFoundPlural() {
|
||||
const {selectMessage} = usePluralForm();
|
||||
return (count: number) =>
|
||||
selectMessage(
|
||||
count,
|
||||
translate(
|
||||
{
|
||||
id: 'theme.SearchPage.documentsFound.plurals',
|
||||
description:
|
||||
'Pluralized label for "{count} documents found". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
|
||||
message: 'One document found|{count} documents found',
|
||||
},
|
||||
{count},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function useDocsSearchVersionsHelpers() {
|
||||
const allDocsData = useAllDocsData();
|
||||
|
||||
// State of the version select menus / algolia facet filters
|
||||
// docsPluginId -> versionName map
|
||||
const [searchVersions, setSearchVersions] = useState<{
|
||||
[pluginId: string]: string;
|
||||
}>(() =>
|
||||
Object.entries(allDocsData).reduce(
|
||||
(acc, [pluginId, pluginData]) => ({
|
||||
...acc,
|
||||
[pluginId]: pluginData.versions[0]!.name,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
// Set the value of a single select menu
|
||||
const setSearchVersion = (pluginId: string, searchVersion: string) =>
|
||||
setSearchVersions((s) => ({...s, [pluginId]: searchVersion}));
|
||||
|
||||
const versioningEnabled = Object.values(allDocsData).some(
|
||||
(docsData) => docsData.versions.length > 1,
|
||||
);
|
||||
|
||||
return {
|
||||
allDocsData,
|
||||
versioningEnabled,
|
||||
searchVersions,
|
||||
setSearchVersion,
|
||||
};
|
||||
}
|
||||
|
||||
// We want to display one select per versioned docs plugin instance
|
||||
function SearchVersionSelectList({
|
||||
docsSearchVersionsHelpers,
|
||||
}: {
|
||||
docsSearchVersionsHelpers: ReturnType<typeof useDocsSearchVersionsHelpers>;
|
||||
}) {
|
||||
const versionedPluginEntries = Object.entries(
|
||||
docsSearchVersionsHelpers.allDocsData,
|
||||
)
|
||||
// Do not show a version select for unversioned docs plugin instances
|
||||
.filter(([, docsData]) => docsData.versions.length > 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'col',
|
||||
'col--3',
|
||||
'padding-left--none',
|
||||
styles.searchVersionColumn,
|
||||
)}>
|
||||
{versionedPluginEntries.map(([pluginId, docsData]) => {
|
||||
const labelPrefix =
|
||||
versionedPluginEntries.length > 1 ? `${pluginId}: ` : '';
|
||||
return (
|
||||
<select
|
||||
key={pluginId}
|
||||
onChange={(e) =>
|
||||
docsSearchVersionsHelpers.setSearchVersion(
|
||||
pluginId,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
defaultValue={docsSearchVersionsHelpers.searchVersions[pluginId]}
|
||||
className={styles.searchVersionInput}>
|
||||
{docsData.versions.map((version, i) => (
|
||||
<option
|
||||
key={i}
|
||||
label={`${labelPrefix}${version.label}`}
|
||||
value={version.name}
|
||||
/>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlgoliaLogo(): ReactNode {
|
||||
return (
|
||||
<svg
|
||||
width="80"
|
||||
height="24"
|
||||
aria-label="Algolia"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 2196.2 500"
|
||||
style={{maxWidth: '150px'}}>
|
||||
<defs>
|
||||
{/* eslint-disable-next-line @docusaurus/no-untranslated-text */}
|
||||
<style>{`.cls-1,.cls-2{fill:#003dff}.cls-2{fill-rule:evenodd}`}</style>
|
||||
</defs>
|
||||
<path
|
||||
className="cls-2"
|
||||
d="M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
|
||||
/>
|
||||
<rect
|
||||
className="cls-1"
|
||||
x="1845.88"
|
||||
y="104.73"
|
||||
width="62.58"
|
||||
height="277.9"
|
||||
rx="5.9"
|
||||
ry="5.9"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
d="M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
d="M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
d="M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
d="M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
d="M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
d="M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
d="M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type ResultDispatcherState = {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
breadcrumbs: string[];
|
||||
}[];
|
||||
query: string | null;
|
||||
totalResults: number | null;
|
||||
totalPages: number | null;
|
||||
lastPage: number | null;
|
||||
hasMore: boolean | null;
|
||||
loading: boolean | null;
|
||||
};
|
||||
|
||||
type ResultDispatcher =
|
||||
| {type: 'reset'; value?: undefined}
|
||||
| {type: 'loading'; value?: undefined}
|
||||
| {type: 'update'; value: ResultDispatcherState}
|
||||
| {type: 'advance'; value?: undefined};
|
||||
|
||||
function getSearchPageTitle(searchQuery: string | undefined): string {
|
||||
return searchQuery
|
||||
? translate(
|
||||
{
|
||||
id: 'theme.SearchPage.existingResultsTitle',
|
||||
message: 'Search results for "{query}"',
|
||||
description: 'The search page title for non-empty query',
|
||||
},
|
||||
{
|
||||
query: searchQuery,
|
||||
},
|
||||
)
|
||||
: translate({
|
||||
id: 'theme.SearchPage.emptyResultsTitle',
|
||||
message: 'Search the documentation',
|
||||
description: 'The search page title for empty query',
|
||||
});
|
||||
}
|
||||
|
||||
function SearchPageContent(): ReactNode {
|
||||
const {
|
||||
i18n: {currentLocale},
|
||||
} = useDocusaurusContext();
|
||||
const {
|
||||
// Mattermost delta: `searchParameters` added (upstream omits it).
|
||||
algolia: {appId, apiKey, indexName, contextualSearch, searchParameters},
|
||||
} = useAlgoliaThemeConfig();
|
||||
const processSearchResultUrl = useSearchResultUrlProcessor();
|
||||
const documentsFoundPlural = useDocumentsFoundPlural();
|
||||
|
||||
const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers();
|
||||
const [searchQuery, setSearchQuery] = useSearchQueryString();
|
||||
const pageTitle = getSearchPageTitle(searchQuery);
|
||||
|
||||
const initialSearchResultState: ResultDispatcherState = {
|
||||
items: [],
|
||||
query: null,
|
||||
totalResults: null,
|
||||
totalPages: null,
|
||||
lastPage: null,
|
||||
hasMore: null,
|
||||
loading: null,
|
||||
};
|
||||
const [searchResultState, searchResultStateDispatcher] = useReducer(
|
||||
(prevState: ResultDispatcherState, data: ResultDispatcher) => {
|
||||
switch (data.type) {
|
||||
case 'reset': {
|
||||
return initialSearchResultState;
|
||||
}
|
||||
case 'loading': {
|
||||
return {...prevState, loading: true};
|
||||
}
|
||||
case 'update': {
|
||||
if (searchQuery !== data.value.query) {
|
||||
return prevState;
|
||||
}
|
||||
|
||||
return {
|
||||
...data.value,
|
||||
items:
|
||||
data.value.lastPage === 0
|
||||
? data.value.items
|
||||
: prevState.items.concat(data.value.items),
|
||||
};
|
||||
}
|
||||
case 'advance': {
|
||||
const hasMore = prevState.totalPages! > prevState.lastPage! + 1;
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
lastPage: hasMore ? prevState.lastPage! + 1 : prevState.lastPage,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return prevState;
|
||||
}
|
||||
},
|
||||
initialSearchResultState,
|
||||
);
|
||||
|
||||
// respect settings from the theme config for facets
|
||||
const disjunctiveFacets = contextualSearch
|
||||
? ['language', 'docusaurus_tag']
|
||||
: [];
|
||||
|
||||
const algoliaClient = liteClient(appId, apiKey);
|
||||
const algoliaHelper = algoliaSearchHelper(algoliaClient, indexName, {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: why errors happens after upgrading to TS 5.5 ?
|
||||
hitsPerPage: 15,
|
||||
advancedSyntax: true,
|
||||
disjunctiveFacets,
|
||||
// Mattermost delta: spread last so theme config wins on conflicts. The
|
||||
// cast bridges DocSearch's `SearchOptions` (where `optionalFilters` also
|
||||
// accepts a bare string) and the helper's narrower array-only type.
|
||||
...(searchParameters as PlainSearchParameters),
|
||||
});
|
||||
|
||||
algoliaHelper.on(
|
||||
'result',
|
||||
({results: {query, hits, page, nbHits, nbPages}}) => {
|
||||
if (query === '' || !Array.isArray(hits)) {
|
||||
searchResultStateDispatcher({type: 'reset'});
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizeValue = (value: string) =>
|
||||
value.replace(
|
||||
/algolia-docsearch-suggestion--highlight/g,
|
||||
'search-result-match',
|
||||
);
|
||||
|
||||
const items = hits.map(
|
||||
({
|
||||
url,
|
||||
_highlightResult: {hierarchy},
|
||||
_snippetResult: snippet = {},
|
||||
}: {
|
||||
url: string;
|
||||
_highlightResult: {hierarchy: {[key: string]: {value: string}}};
|
||||
_snippetResult: {content?: {value: string}};
|
||||
}) => {
|
||||
const titles = Object.keys(hierarchy).map((key) =>
|
||||
sanitizeValue(hierarchy[key]!.value),
|
||||
);
|
||||
return {
|
||||
title: titles.pop()!,
|
||||
url: processSearchResultUrl(url),
|
||||
summary: snippet.content
|
||||
? `${sanitizeValue(snippet.content.value)}...`
|
||||
: '',
|
||||
breadcrumbs: titles,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
searchResultStateDispatcher({
|
||||
type: 'update',
|
||||
value: {
|
||||
items,
|
||||
query,
|
||||
totalResults: nbHits,
|
||||
totalPages: nbPages,
|
||||
lastPage: page,
|
||||
hasMore: nbPages > page + 1,
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const [loaderRef, setLoaderRef] = useState<HTMLDivElement | null>(null);
|
||||
const prevY = useRef(0);
|
||||
const observer = useRef(
|
||||
ExecutionEnvironment.canUseIntersectionObserver &&
|
||||
new IntersectionObserver(
|
||||
// TODO need to fix this React Compiler lint error
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
(entries) => {
|
||||
const {
|
||||
isIntersecting,
|
||||
boundingClientRect: {y: currentY},
|
||||
} = entries[0]!;
|
||||
|
||||
if (isIntersecting && prevY.current > currentY) {
|
||||
searchResultStateDispatcher({type: 'advance'});
|
||||
}
|
||||
|
||||
prevY.current = currentY;
|
||||
},
|
||||
{threshold: 1},
|
||||
),
|
||||
);
|
||||
|
||||
const makeSearch = useEvent((page: number = 0) => {
|
||||
if (contextualSearch) {
|
||||
algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default');
|
||||
algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale);
|
||||
|
||||
Object.entries(docsSearchVersionsHelpers.searchVersions).forEach(
|
||||
([pluginId, searchVersion]) => {
|
||||
algoliaHelper.addDisjunctiveFacetRefinement(
|
||||
'docusaurus_tag',
|
||||
`docs-${pluginId}-${searchVersion}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
algoliaHelper.setQuery(searchQuery).setPage(page).search();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaderRef) {
|
||||
return undefined;
|
||||
}
|
||||
const currentObserver = observer.current;
|
||||
if (currentObserver) {
|
||||
currentObserver.observe(loaderRef);
|
||||
return () => currentObserver.unobserve(loaderRef);
|
||||
}
|
||||
return () => true;
|
||||
}, [loaderRef]);
|
||||
|
||||
useEffect(() => {
|
||||
searchResultStateDispatcher({type: 'reset'});
|
||||
|
||||
if (searchQuery) {
|
||||
searchResultStateDispatcher({type: 'loading'});
|
||||
|
||||
setTimeout(() => {
|
||||
makeSearch();
|
||||
}, 300);
|
||||
}
|
||||
}, [searchQuery, docsSearchVersionsHelpers.searchVersions, makeSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchResultState.lastPage || searchResultState.lastPage === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
makeSearch(searchResultState.lastPage);
|
||||
}, [makeSearch, searchResultState.lastPage]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PageMetadata title={pageTitle} />
|
||||
|
||||
<Head>
|
||||
{/*
|
||||
We should not index search pages
|
||||
See https://github.com/facebook/docusaurus/pull/3233
|
||||
*/}
|
||||
<meta property="robots" content="noindex, follow" />
|
||||
</Head>
|
||||
|
||||
<div className="container margin-vert--lg">
|
||||
<Heading as="h1">{pageTitle}</Heading>
|
||||
|
||||
<form className="row" onSubmit={(e) => e.preventDefault()}>
|
||||
<div
|
||||
className={clsx('col', styles.searchQueryColumn, {
|
||||
'col--9': docsSearchVersionsHelpers.versioningEnabled,
|
||||
'col--12': !docsSearchVersionsHelpers.versioningEnabled,
|
||||
})}>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
className={styles.searchQueryInput}
|
||||
placeholder={translate({
|
||||
id: 'theme.SearchPage.inputPlaceholder',
|
||||
message: 'Type your search here',
|
||||
description: 'The placeholder for search page input',
|
||||
})}
|
||||
aria-label={translate({
|
||||
id: 'theme.SearchPage.inputLabel',
|
||||
message: 'Search',
|
||||
description: 'The ARIA label for search page input',
|
||||
})}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{contextualSearch && docsSearchVersionsHelpers.versioningEnabled && (
|
||||
<SearchVersionSelectList
|
||||
docsSearchVersionsHelpers={docsSearchVersionsHelpers}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="row">
|
||||
<div className={clsx('col', 'col--8', styles.searchResultsColumn)}>
|
||||
{!!searchResultState.totalResults &&
|
||||
documentsFoundPlural(searchResultState.totalResults)}
|
||||
</div>
|
||||
|
||||
<div className={clsx('col', 'col--4', styles.searchLogoColumn)}>
|
||||
<span>
|
||||
{translate({
|
||||
id: 'theme.SearchPage.algoliaLabel',
|
||||
message: 'Powered by',
|
||||
description:
|
||||
'The text explain that the search powered by Algolia',
|
||||
})}
|
||||
</span>
|
||||
<Link
|
||||
to="https://www.algolia.com/"
|
||||
aria-label={translate({
|
||||
id: 'theme.SearchPage.algoliaLabel',
|
||||
message: 'Powered by Algolia',
|
||||
description: 'The description label for Algolia mention',
|
||||
})}>
|
||||
<AlgoliaLogo />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchResultState.items.length > 0 ? (
|
||||
<main>
|
||||
{searchResultState.items.map(
|
||||
({title, url, summary, breadcrumbs}, i) => (
|
||||
<article key={i} className={styles.searchResultItem}>
|
||||
<Heading as="h2" className={styles.searchResultItemHeading}>
|
||||
<Link to={url} dangerouslySetInnerHTML={{__html: title}} />
|
||||
</Heading>
|
||||
|
||||
{breadcrumbs.length > 0 && (
|
||||
<nav aria-label="breadcrumbs">
|
||||
<ul
|
||||
className={clsx(
|
||||
'breadcrumbs',
|
||||
styles.searchResultItemPath,
|
||||
)}>
|
||||
{breadcrumbs.map((html, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="breadcrumbs__item"
|
||||
// Developer provided the HTML, so assume it's safe.
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{__html: html}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<p
|
||||
className={styles.searchResultItemSummary}
|
||||
// Developer provided the HTML, so assume it's safe.
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{__html: summary}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
),
|
||||
)}
|
||||
</main>
|
||||
) : (
|
||||
[
|
||||
searchQuery && !searchResultState.loading && (
|
||||
<p key="no-results">
|
||||
<Translate
|
||||
id="theme.SearchPage.noResultsText"
|
||||
description="The paragraph for empty search result">
|
||||
No results were found
|
||||
</Translate>
|
||||
</p>
|
||||
),
|
||||
!!searchResultState.loading && (
|
||||
<div key="spinner" className={styles.loadingSpinner} />
|
||||
),
|
||||
]
|
||||
)}
|
||||
|
||||
{searchResultState.hasMore && (
|
||||
<div className={styles.loader} ref={setLoaderRef}>
|
||||
<Translate
|
||||
id="theme.SearchPage.fetchingNewResults"
|
||||
description="The paragraph for fetching new search results">
|
||||
Fetching new results...
|
||||
</Translate>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage(): ReactNode {
|
||||
return (
|
||||
<HtmlClassNameProvider className="search-page-wrapper">
|
||||
<SearchPageContent />
|
||||
</HtmlClassNameProvider>
|
||||
);
|
||||
}
|
||||
120
docs/site/src/theme/SearchPage/styles.module.css
Normal file
120
docs/site/src/theme/SearchPage/styles.module.css
Normal file
@@ -0,0 +1,120 @@
|
||||
.searchQueryInput,
|
||||
.searchVersionInput {
|
||||
border-radius: var(--ifm-global-radius);
|
||||
border: 2px solid var(--ifm-toc-border-color);
|
||||
font: var(--ifm-font-size-base) var(--ifm-font-family-base);
|
||||
padding: 0.8rem;
|
||||
width: 100%;
|
||||
background: var(--docsearch-searchbox-focus-background);
|
||||
color: var(--docsearch-text-color);
|
||||
margin-bottom: 0.5rem;
|
||||
transition: border var(--ifm-transition-fast) ease;
|
||||
}
|
||||
|
||||
.searchQueryInput:focus,
|
||||
.searchVersionInput:focus {
|
||||
border-color: var(--docsearch-primary-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.searchQueryInput::placeholder {
|
||||
color: var(--docsearch-muted-color);
|
||||
}
|
||||
|
||||
.searchResultsColumn {
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.searchLogoColumn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.searchLogoColumn a {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.searchLogoColumn span {
|
||||
color: var(--docsearch-muted-color);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.searchResultItem {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--ifm-toc-border-color);
|
||||
}
|
||||
|
||||
.searchResultItemHeading {
|
||||
font-weight: 400;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchResultItemPath {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
--ifm-breadcrumb-separator-size-multiplier: 1;
|
||||
}
|
||||
|
||||
.searchResultItemSummary {
|
||||
margin: 0.5rem 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 996px) {
|
||||
.searchQueryColumn {
|
||||
max-width: 60% !important;
|
||||
}
|
||||
|
||||
.searchVersionColumn {
|
||||
max-width: 40% !important;
|
||||
}
|
||||
|
||||
.searchResultsColumn {
|
||||
max-width: 60% !important;
|
||||
}
|
||||
|
||||
.searchLogoColumn {
|
||||
max-width: 40% !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 576px) {
|
||||
.searchQueryColumn {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.searchVersionColumn {
|
||||
max-width: 100% !important;
|
||||
padding-left: var(--ifm-spacing-horizontal) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingSpinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 0.4em solid #eee;
|
||||
border-top-color: var(--ifm-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: loading-spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes loading-spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
:global(.search-result-match) {
|
||||
color: var(--docsearch-hit-color);
|
||||
background: rgb(255 215 142 / 25%);
|
||||
padding: 0.09em 0;
|
||||
}
|
||||
Reference in New Issue
Block a user