diff --git a/examples/all.yaml b/examples/all.yaml index 66fda80..cbc0531 100644 --- a/examples/all.yaml +++ b/examples/all.yaml @@ -12,4 +12,5 @@ spec: - ./resources/*.yaml - ./domains/*.yaml - ./apis/*.yaml - - ./templates/*/template.yaml \ No newline at end of file + - ./templates/*/template.yaml + - ./portal-docs/catalog-info.yaml \ No newline at end of file diff --git a/examples/portal-docs/catalog-info.yaml b/examples/portal-docs/catalog-info.yaml new file mode 100644 index 0000000..4fb9ff3 --- /dev/null +++ b/examples/portal-docs/catalog-info.yaml @@ -0,0 +1,16 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: portal-docs + title: Developer Portal Docs + description: Onboarding, support, and how-to guides for the Developer Portal. + tags: + - onboarding + - getting-started + - support + annotations: + backstage.io/techdocs-ref: dir:. +spec: + type: documentation + lifecycle: production + owner: group:platform-engineering diff --git a/examples/portal-docs/docs/index.md b/examples/portal-docs/docs/index.md new file mode 100644 index 0000000..eb58894 --- /dev/null +++ b/examples/portal-docs/docs/index.md @@ -0,0 +1,44 @@ +# Getting Started with the Developer Portal + +Welcome! This guide walks you through your first five minutes in the portal. + +## 1. Find what already exists + +Use the search bar on the homepage or browse the [Catalog](/catalog) to find +services, APIs, systems, and teams. Every entity has an owner, a lifecycle, +and links out to source code and documentation. + +## 2. Create your first service + +Head to **Start here → Create your first service** on the homepage, or go +straight to [Self-Service](/self-service). Pick a template, fill in the form, +and the scaffolder will: + +1. Create a repository in GitHub or GitLab. +2. Seed it with a starter skeleton (code, docs, CI-ready layout). +3. Register the new component in the catalog so it shows up immediately. + +## 3. Register an existing repo + +Already have a repository? Use +[Catalog Import](/catalog-import) to add a `catalog-info.yaml` to it and +register it here. Once registered it appears in search, the catalog graph, +and your team's ownership views. + +## 4. Read the docs + +Technical documentation lives in [Docs](/docs). Anything tagged +`onboarding` or `getting-started` is surfaced directly on the homepage. + +## 5. Explore APIs + +The [API Explorer](/api-docs) lists every API registered in the catalog, +with OpenAPI/AsyncAPI definitions rendered inline. + +## What's next? + +- Check the **System Health** panel on the homepage for a snapshot of the + catalog's lifecycle states. +- Visit the [Scorecard](/scorecard) to see how entities measure up against + quality checks. +- See [Support & FAQ](support.md) if you get stuck. diff --git a/examples/portal-docs/docs/support.md b/examples/portal-docs/docs/support.md new file mode 100644 index 0000000..dfb7f16 --- /dev/null +++ b/examples/portal-docs/docs/support.md @@ -0,0 +1,43 @@ +# Support & FAQ + +How to get unstuck when something in the portal isn't working. + +## Where to get help + +1. **This documentation** — start with [Getting Started](index.md). +2. **The catalog** — every entity lists an owner. For questions about a + specific service or API, contact the owning team shown on the entity page. +3. **Platform Engineering** — owns the portal itself (search, scaffolder, + TechDocs, catalog ingestion). See the `platform-engineering` group in the + catalog for current members. + +## Frequently asked questions + +### My component doesn't show up in the catalog + +- Confirm the repo has a `catalog-info.yaml` at its root. +- Confirm the file is registered: use + [Catalog Import](/catalog-import) to locate and register it. +- Check for YAML syntax errors; a single bad entity file can be rejected by + the catalog ingestion loop. + +### My TechDocs site isn't building + +- Confirm the entity has the `backstage.io/techdocs-ref` annotation. +- Confirm `mkdocs.yml` exists at the referenced path and includes the + `techdocs-core` plugin. +- Docs build on first read; the first page load can take a minute. + +### A scaffolder run failed + +- Open the failed task from [Self-Service](/self-service) and expand the + step logs — the failing step is highlighted. +- Most failures are permissions: the scaffolder needs a token with write + access to the target GitHub org or GitLab group. + +### How do I add a new software template? + +Templates live under `examples/templates/` in the portal repository. Add a +folder with a `template.yaml` and a `template/` skeleton, tag it +`recommended` plus a `family:` tag, and it appears on the homepage and the +Self-Service page automatically. diff --git a/examples/portal-docs/mkdocs.yml b/examples/portal-docs/mkdocs.yml new file mode 100644 index 0000000..443df9c --- /dev/null +++ b/examples/portal-docs/mkdocs.yml @@ -0,0 +1,9 @@ +site_name: Developer Portal Docs +site_description: Onboarding, support, and how-to guides for the Developer Portal. + +nav: + - Getting Started: index.md + - Support & FAQ: support.md + +plugins: + - techdocs-core diff --git a/examples/templates/ai-hub-service/template.yaml b/examples/templates/ai-hub-service/template.yaml new file mode 100644 index 0000000..d2c4b20 --- /dev/null +++ b/examples/templates/ai-hub-service/template.yaml @@ -0,0 +1,106 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: ai-hub-service + title: AI Hub Service + tags: + - recommended + - family:ai-native + - provider:github + - ai + - llm + description: | + Scaffold an AI Hub service: a thin, provider-agnostic gateway in front of + your organisation's LLM providers. Centralises authentication, rate + limiting, cost tracking, and audit logging for inference traffic. + Ships with an AGENTS.md, a TechDocs site, and catalog registration. +spec: + owner: platform-engineering + type: service + + parameters: + - title: Service details + required: + - owner + - name + properties: + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + catalogFilter: + kind: [Group, User] + name: + title: Name + type: string + description: Unique name of the service + ui:placeholder: ai-hub + description: + title: Description + type: string + description: What this AI Hub fronts (providers, teams, use cases) + + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + requestUserCredentials: + secretsKey: USER_OAUTH_TOKEN + allowedHosts: + - github.com + allowedOwners: + - echohello-dev + allowedOrganizations: + - echohello-dev + + steps: + - id: fetch-base + name: Fetch Base + action: fetch:template + input: + url: ./template + values: + name: ${{ parameters.name }} + owner: ${{ parameters.owner }} + destination: ${{ parameters.repoUrl | parseRepoUrl }} + description: ${{ parameters.description }} + + - id: publish + name: Publish + action: publish:github + input: + allowedHosts: + - github.com + description: ${{ parameters.description }} + repoUrl: ${{ parameters.repoUrl }} + token: ${{ secrets.USER_OAUTH_TOKEN }} + gitAuthorName: ${{ user.entity.metadata.name }} + gitAuthorEmail: ${{ user.entity.spec.profile.email }} + visibility: public + + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }} + catalogInfoPath: /catalog-info.yaml + + output: + links: + - title: Repository + url: ${{ steps.publish.output.remoteUrl }} + - title: Open in catalog + icon: catalog + entityRef: ${{ steps.register.output.entityRef }} + text: + - title: Next steps + content: | + Your AI Hub skeleton is ready. Wire a provider in `src/providers/`, + then update `AGENTS.md` with the conventions your team settles on. diff --git a/examples/templates/ai-hub-service/template/AGENTS.md b/examples/templates/ai-hub-service/template/AGENTS.md new file mode 100644 index 0000000..31ff102 --- /dev/null +++ b/examples/templates/ai-hub-service/template/AGENTS.md @@ -0,0 +1,27 @@ +# Agent instructions for ${{ values.name }} + +## What this repo is + +An AI Hub gateway: a single, provider-agnostic front door for LLM inference +traffic. Provider SDKs are never called directly by consumers — everything +goes through this service. + +## Commands + +- Build: `npm run build` +- Run locally: `npm start` (after build), listens on `PORT` (default 8080) +- Type-check without emitting: `npx tsc --noEmit` + +## Conventions + +- One file per provider in `src/providers/`, each exporting a + `complete(request): Promise`-shaped function. +- No provider SDK imports outside `src/providers/`. +- No secrets in the repo. Provider credentials come from environment + variables only. +- Keep request/response shapes stable; consumers depend on them. + +## When editing + +- Update `docs/` when behaviour, endpoints, or configuration change. +- Keep this file short — move detail into `docs/` and link to it. diff --git a/examples/templates/ai-hub-service/template/Dockerfile b/examples/templates/ai-hub-service/template/Dockerfile new file mode 100644 index 0000000..d57a864 --- /dev/null +++ b/examples/templates/ai-hub-service/template/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN npm install +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package.json ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist +EXPOSE 8080 +CMD ["node", "dist/index.js"] diff --git a/examples/templates/ai-hub-service/template/README.md b/examples/templates/ai-hub-service/template/README.md new file mode 100644 index 0000000..7e2b7c2 --- /dev/null +++ b/examples/templates/ai-hub-service/template/README.md @@ -0,0 +1,35 @@ +# ${{ values.name }} + +{% if values.description %}${{ values.description }}{% else %}AI Hub gateway service.{% endif %} + +A thin, provider-agnostic gateway in front of one or more LLM providers. +Route inference traffic through this service so authentication, rate +limiting, cost tracking, and audit logging live in exactly one place. + +## Quick start + +```bash +npm install +npm run build +npm start +``` + +The server listens on `PORT` (default `8080`): + +- `GET /healthz` — liveness probe +- `POST /v1/answer` — single entry point for inference requests (stubbed + until a provider is wired in `src/providers/`) + +## Layout + +| Path | Purpose | +|---|---| +| `src/index.ts` | HTTP server and route wiring | +| `src/providers/` | One file per LLM provider (add yours here) | +| `docs/` | TechDocs site source | +| `AGENTS.md` | Repo instructions for AI coding tools | + +## Documentation + +Rendered docs are published via TechDocs — see the **Docs** tab on this +component's page in the Developer Portal. diff --git a/examples/templates/ai-hub-service/template/catalog-info.yaml b/examples/templates/ai-hub-service/template/catalog-info.yaml new file mode 100644 index 0000000..c2e2285 --- /dev/null +++ b/examples/templates/ai-hub-service/template/catalog-info.yaml @@ -0,0 +1,21 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: ${{ values.name | dump }} + description: >- + {% if values.description %} + ${{ values.description | dump }} + {% else %} + AI Hub gateway service + {% endif %} + tags: + - ai-native + - ai-hub + - llm + annotations: + github.com/project-slug: ${{ values.destination.owner + "/" + values.destination.repo }} + backstage.io/techdocs-ref: dir:. +spec: + type: service + lifecycle: experimental + owner: ${{ values.owner | dump }} diff --git a/examples/templates/ai-hub-service/template/docs/index.md b/examples/templates/ai-hub-service/template/docs/index.md new file mode 100644 index 0000000..91e50d9 --- /dev/null +++ b/examples/templates/ai-hub-service/template/docs/index.md @@ -0,0 +1,32 @@ +# ${{ values.name }} + +{% if values.description %}${{ values.description }}{% else %}AI Hub gateway service.{% endif %} + +## What it does + +This service is the single entry point for LLM inference traffic. Consumers +call `POST /v1/answer`; the hub handles authentication, rate limiting, cost +tracking, and routing to the configured provider. + +## Endpoints + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/healthz` | Liveness probe | +| `POST` | `/v1/answer` | Submit a prompt, receive an answer | + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `PORT` | `8080` | Listen port | + +Provider credentials are read from environment variables. Never commit +secrets to this repository. + +## Adding a provider + +1. Create `src/providers/.ts` implementing the `Provider` interface + from `src/providers/index.ts`. +2. Instantiate it in `src/index.ts` and route `/v1/answer` to it. +3. Document the provider and its configuration here. diff --git a/examples/templates/ai-hub-service/template/mkdocs.yml b/examples/templates/ai-hub-service/template/mkdocs.yml new file mode 100644 index 0000000..2d9dade --- /dev/null +++ b/examples/templates/ai-hub-service/template/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: ${{ values.name | dump }} +{% if values.description %} +site_description: ${{ values.description | dump }} +{% endif %} + +nav: + - Overview: index.md + +plugins: + - techdocs-core diff --git a/examples/templates/ai-hub-service/template/package.json b/examples/templates/ai-hub-service/template/package.json new file mode 100644 index 0000000..0ae6d98 --- /dev/null +++ b/examples/templates/ai-hub-service/template/package.json @@ -0,0 +1,17 @@ +{ + "name": "${{ values.name }}", + "version": "0.1.0", + "private": true, + "description": "{% if values.description %}${{ values.description }}{% else %}AI Hub gateway service{% endif %}", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "dev": "tsc -p tsconfig.json --watch", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.5.0" + } +} diff --git a/examples/templates/ai-hub-service/template/src/index.ts b/examples/templates/ai-hub-service/template/src/index.ts new file mode 100644 index 0000000..7d17630 --- /dev/null +++ b/examples/templates/ai-hub-service/template/src/index.ts @@ -0,0 +1,61 @@ +import { createServer, IncomingMessage, ServerResponse } from 'node:http'; + +const port = Number(process.env.PORT ?? 8080); + +interface AnswerRequest { + prompt: string; + model?: string; +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function handleAnswer( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const raw = await readBody(req); + let payload: AnswerRequest; + try { + payload = JSON.parse(raw) as AnswerRequest; + } catch { + sendJson(res, 400, { error: 'request body must be JSON' }); + return; + } + if (!payload.prompt) { + sendJson(res, 400, { error: 'prompt is required' }); + return; + } + sendJson(res, 501, { + error: 'no provider configured', + hint: 'add a provider in src/providers/ and route to it here', + }); +} + +const server = createServer((req, res) => { + if (req.method === 'GET' && req.url === '/healthz') { + sendJson(res, 200, { status: 'ok' }); + return; + } + if (req.method === 'POST' && req.url === '/v1/answer') { + handleAnswer(req, res).catch(err => { + sendJson(res, 500, { error: String(err) }); + }); + return; + } + sendJson(res, 404, { error: 'not found' }); +}); + +server.listen(port, () => { + console.log(`ai-hub listening on :${port}`); +}); diff --git a/examples/templates/ai-hub-service/template/src/providers/index.ts b/examples/templates/ai-hub-service/template/src/providers/index.ts new file mode 100644 index 0000000..b7685ce --- /dev/null +++ b/examples/templates/ai-hub-service/template/src/providers/index.ts @@ -0,0 +1,15 @@ +export interface AnswerRequest { + prompt: string; + model?: string; +} + +export interface Answer { + text: string; + model: string; + provider: string; +} + +export interface Provider { + name: string; + complete(request: AnswerRequest): Promise; +} diff --git a/examples/templates/ai-hub-service/template/tsconfig.json b/examples/templates/ai-hub-service/template/tsconfig.json new file mode 100644 index 0000000..10c9723 --- /dev/null +++ b/examples/templates/ai-hub-service/template/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/templates/answer-engine-worker/template.yaml b/examples/templates/answer-engine-worker/template.yaml new file mode 100644 index 0000000..a04e413 --- /dev/null +++ b/examples/templates/answer-engine-worker/template.yaml @@ -0,0 +1,107 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: answer-engine-worker + title: Answer Engine Worker + tags: + - recommended + - family:ai-native + - provider:github + - ai + - rag + description: | + Scaffold an answer-engine worker: a queue consumer that picks up + questions, retrieves context from a document index, and produces cited + answers. Ships with an indexer, a query API, an AGENTS.md, a TechDocs + site, and catalog registration. +spec: + owner: platform-engineering + type: service + + parameters: + - title: Worker details + required: + - owner + - name + properties: + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + catalogFilter: + kind: [Group, User] + name: + title: Name + type: string + description: Unique name of the worker + ui:placeholder: answer-engine + description: + title: Description + type: string + description: What this answer engine covers (sources, audience) + + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + requestUserCredentials: + secretsKey: USER_OAUTH_TOKEN + allowedHosts: + - github.com + allowedOwners: + - echohello-dev + allowedOrganizations: + - echohello-dev + + steps: + - id: fetch-base + name: Fetch Base + action: fetch:template + input: + url: ./template + values: + name: ${{ parameters.name }} + owner: ${{ parameters.owner }} + destination: ${{ parameters.repoUrl | parseRepoUrl }} + description: ${{ parameters.description }} + + - id: publish + name: Publish + action: publish:github + input: + allowedHosts: + - github.com + description: ${{ parameters.description }} + repoUrl: ${{ parameters.repoUrl }} + token: ${{ secrets.USER_OAUTH_TOKEN }} + gitAuthorName: ${{ user.entity.metadata.name }} + gitAuthorEmail: ${{ user.entity.spec.profile.email }} + visibility: public + + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }} + catalogInfoPath: /catalog-info.yaml + + output: + links: + - title: Repository + url: ${{ steps.publish.output.remoteUrl }} + - title: Open in catalog + icon: catalog + entityRef: ${{ steps.register.output.entityRef }} + text: + - title: Next steps + content: | + Your answer-engine skeleton is ready. Connect a queue in + `src/consumer.ts`, a document source in `src/indexer.ts`, and an + AI Hub endpoint in `src/api.ts`. diff --git a/examples/templates/answer-engine-worker/template/AGENTS.md b/examples/templates/answer-engine-worker/template/AGENTS.md new file mode 100644 index 0000000..03c5751 --- /dev/null +++ b/examples/templates/answer-engine-worker/template/AGENTS.md @@ -0,0 +1,27 @@ +# Agent instructions for ${{ values.name }} + +## What this repo is + +An answer-engine worker: queue consumer + document indexer + query API. +It retrieves context from an index and delegates inference to an AI Hub — +it never calls an LLM provider directly. + +## Commands + +- Build: `npm run build` +- Run locally: `npm start` (after build), listens on `PORT` (default 8080) +- Type-check without emitting: `npx tsc --noEmit` + +## Conventions + +- Inference goes through the AI Hub endpoint (`AI_HUB_URL`), never through + provider SDKs in this repo. +- Retrieval logic stays in `src/indexer.ts`; queue mechanics stay in + `src/consumer.ts`; HTTP surface stays in `src/api.ts`. +- Answers must carry citations — an answer without sources is a bug. +- No secrets in the repo; configuration comes from environment variables. + +## When editing + +- Update `docs/` when the pipeline, endpoints, or configuration change. +- Keep this file short — move detail into `docs/` and link to it. diff --git a/examples/templates/answer-engine-worker/template/Dockerfile b/examples/templates/answer-engine-worker/template/Dockerfile new file mode 100644 index 0000000..d57a864 --- /dev/null +++ b/examples/templates/answer-engine-worker/template/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN npm install +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package.json ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist +EXPOSE 8080 +CMD ["node", "dist/index.js"] diff --git a/examples/templates/answer-engine-worker/template/README.md b/examples/templates/answer-engine-worker/template/README.md new file mode 100644 index 0000000..94d6477 --- /dev/null +++ b/examples/templates/answer-engine-worker/template/README.md @@ -0,0 +1,32 @@ +# ${{ values.name }} + +{% if values.description %}${{ values.description }}{% else %}Answer engine worker.{% endif %} + +A worker that backs an answer engine: it consumes questions from a queue, +retrieves supporting context from a document index, and produces cited +answers. Designed to sit behind an AI Hub for the actual inference call. + +## Quick start + +```bash +npm install +npm run build +npm start +``` + +## Layout + +| Path | Purpose | +|---|---| +| `src/index.ts` | Entry point — starts the API and the consumer loop | +| `src/consumer.ts` | Queue consumer — picks up question jobs | +| `src/indexer.ts` | Document indexer — builds the retrieval index | +| `src/api.ts` | Query API — synchronous question answering | +| `docs/` | TechDocs site source | +| `AGENTS.md` | Repo instructions for AI coding tools | + +## The pipeline + +```text +question → queue → consumer → retrieve (index) → answer (AI Hub) → cited answer +``` diff --git a/examples/templates/answer-engine-worker/template/catalog-info.yaml b/examples/templates/answer-engine-worker/template/catalog-info.yaml new file mode 100644 index 0000000..317ae8c --- /dev/null +++ b/examples/templates/answer-engine-worker/template/catalog-info.yaml @@ -0,0 +1,21 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: ${{ values.name | dump }} + description: >- + {% if values.description %} + ${{ values.description | dump }} + {% else %} + Answer engine worker + {% endif %} + tags: + - ai-native + - answer-engine + - rag + annotations: + github.com/project-slug: ${{ values.destination.owner + "/" + values.destination.repo }} + backstage.io/techdocs-ref: dir:. +spec: + type: service + lifecycle: experimental + owner: ${{ values.owner | dump }} diff --git a/examples/templates/answer-engine-worker/template/docs/index.md b/examples/templates/answer-engine-worker/template/docs/index.md new file mode 100644 index 0000000..3324e96 --- /dev/null +++ b/examples/templates/answer-engine-worker/template/docs/index.md @@ -0,0 +1,34 @@ +# ${{ values.name }} + +{% if values.description %}${{ values.description }}{% else %}Answer engine worker.{% endif %} + +## What it does + +Consumes questions from a queue, retrieves supporting context from a +document index, and produces cited answers. Inference is delegated to an +AI Hub — this worker owns retrieval, orchestration, and citation. + +## Endpoints + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/healthz` | Liveness probe | +| `POST` | `/v1/ask` | Ask a question synchronously | +| `POST` | `/v1/index` | Add a document to the retrieval index | + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `PORT` | `8080` | Listen port | +| `AI_HUB_URL` | _(unset)_ | Base URL of the AI Hub used for inference | +| `QUEUE_POLL_INTERVAL_MS` | `5000` | Queue poll interval | + +## Replacing the stubs + +- **Queue** — implement the `Queue` interface in `src/consumer.ts` against + your broker (SQS, Pub/Sub, Kafka). +- **Index** — swap the in-memory index in `src/indexer.ts` for your vector + store or search backend. +- **Answers** — answers must always carry citations; treat an uncited + answer as a bug. diff --git a/examples/templates/answer-engine-worker/template/mkdocs.yml b/examples/templates/answer-engine-worker/template/mkdocs.yml new file mode 100644 index 0000000..2d9dade --- /dev/null +++ b/examples/templates/answer-engine-worker/template/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: ${{ values.name | dump }} +{% if values.description %} +site_description: ${{ values.description | dump }} +{% endif %} + +nav: + - Overview: index.md + +plugins: + - techdocs-core diff --git a/examples/templates/answer-engine-worker/template/package.json b/examples/templates/answer-engine-worker/template/package.json new file mode 100644 index 0000000..08b83d0 --- /dev/null +++ b/examples/templates/answer-engine-worker/template/package.json @@ -0,0 +1,17 @@ +{ + "name": "${{ values.name }}", + "version": "0.1.0", + "private": true, + "description": "{% if values.description %}${{ values.description }}{% else %}Answer engine worker{% endif %}", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "dev": "tsc -p tsconfig.json --watch", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.5.0" + } +} diff --git a/examples/templates/answer-engine-worker/template/src/api.ts b/examples/templates/answer-engine-worker/template/src/api.ts new file mode 100644 index 0000000..352ee57 --- /dev/null +++ b/examples/templates/answer-engine-worker/template/src/api.ts @@ -0,0 +1,54 @@ +import { search, IndexedDocument } from './indexer.js'; + +export interface Question { + id: string; + text: string; +} + +export interface CitedAnswer { + questionId: string; + answer: string; + sources: Array<{ title: string; url: string }>; +} + +const aiHubUrl = process.env.AI_HUB_URL; + +async function callAiHub(prompt: string): Promise { + if (!aiHubUrl) { + return 'AI Hub is not configured (set AI_HUB_URL). This is a stub answer.'; + } + const res = await fetch(`${aiHubUrl}/v1/answer`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt }), + }); + if (!res.ok) { + throw new Error(`AI Hub responded ${res.status}`); + } + const body = (await res.json()) as { text?: string }; + return body.text ?? ''; +} + +function buildPrompt(question: string, context: IndexedDocument[]): string { + const passages = context + .map(d => `# ${d.title}\n${d.content}`) + .join('\n\n'); + return [ + 'Answer the question using only the context below. Cite sources.', + '', + '## Context', + passages || '(no context found)', + '', + `## Question\n${question}`, + ].join('\n'); +} + +export async function answer(question: Question): Promise { + const context = search(question.text); + const text = await callAiHub(buildPrompt(question.text, context)); + return { + questionId: question.id, + answer: text, + sources: context.map(d => ({ title: d.title, url: d.url })), + }; +} diff --git a/examples/templates/answer-engine-worker/template/src/consumer.ts b/examples/templates/answer-engine-worker/template/src/consumer.ts new file mode 100644 index 0000000..5b78f32 --- /dev/null +++ b/examples/templates/answer-engine-worker/template/src/consumer.ts @@ -0,0 +1,25 @@ +import { answer, CitedAnswer, Question } from './api.js'; + +export interface Queue { + receive(): Promise; + ack(question: Question, result: CitedAnswer): Promise; + nack(question: Question, error: unknown): Promise; +} + +const pollIntervalMs = Number(process.env.QUEUE_POLL_INTERVAL_MS ?? 5000); + +export async function consume(queue: Queue): Promise { + for (;;) { + const question = await queue.receive(); + if (!question) { + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + continue; + } + try { + const result = await answer(question); + await queue.ack(question, result); + } catch (error) { + await queue.nack(question, error); + } + } +} diff --git a/examples/templates/answer-engine-worker/template/src/index.ts b/examples/templates/answer-engine-worker/template/src/index.ts new file mode 100644 index 0000000..79bc6a5 --- /dev/null +++ b/examples/templates/answer-engine-worker/template/src/index.ts @@ -0,0 +1,70 @@ +import { createServer, IncomingMessage, ServerResponse } from 'node:http'; +import { answer } from './api.js'; +import { consume, Queue } from './consumer.js'; +import { indexDocument } from './indexer.js'; + +const port = Number(process.env.PORT ?? 8080); + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +const server = createServer((req, res) => { + if (req.method === 'GET' && req.url === '/healthz') { + sendJson(res, 200, { status: 'ok' }); + return; + } + if (req.method === 'POST' && req.url === '/v1/ask') { + readBody(req) + .then(raw => { + const body = JSON.parse(raw) as { question?: string }; + if (!body.question) { + sendJson(res, 400, { error: 'question is required' }); + return undefined; + } + return answer({ id: crypto.randomUUID(), text: body.question }).then( + result => sendJson(res, 200, result), + ); + }) + .catch(err => sendJson(res, 500, { error: String(err) })); + return; + } + if (req.method === 'POST' && req.url === '/v1/index') { + readBody(req) + .then(raw => { + const doc = JSON.parse(raw) as Parameters[0]; + indexDocument(doc); + sendJson(res, 202, { indexed: doc.id }); + }) + .catch(err => sendJson(res, 400, { error: String(err) })); + return; + } + sendJson(res, 404, { error: 'not found' }); +}); + +server.listen(port, () => { + console.log(`answer-engine listening on :${port}`); +}); + +const disabledQueue: Queue = { + receive: () => Promise.resolve(undefined), + ack: () => Promise.resolve(), + nack: (_question, error) => { + console.error('question failed', error); + return Promise.resolve(); + }, +}; + +consume(disabledQueue).catch(err => { + console.error('consumer loop exited', err); + process.exit(1); +}); diff --git a/examples/templates/answer-engine-worker/template/src/indexer.ts b/examples/templates/answer-engine-worker/template/src/indexer.ts new file mode 100644 index 0000000..76a3cba --- /dev/null +++ b/examples/templates/answer-engine-worker/template/src/indexer.ts @@ -0,0 +1,29 @@ +export interface IndexedDocument { + id: string; + title: string; + url: string; + content: string; +} + +const documents: IndexedDocument[] = []; + +export function indexDocument(doc: IndexedDocument): void { + documents.push(doc); +} + +export function search(query: string, limit = 5): IndexedDocument[] { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + return documents + .map(doc => ({ + doc, + score: terms.filter( + t => + doc.title.toLowerCase().includes(t) || + doc.content.toLowerCase().includes(t), + ).length, + })) + .filter(hit => hit.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(hit => hit.doc); +} diff --git a/examples/templates/answer-engine-worker/template/tsconfig.json b/examples/templates/answer-engine-worker/template/tsconfig.json new file mode 100644 index 0000000..10c9723 --- /dev/null +++ b/examples/templates/answer-engine-worker/template/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/templates/github-blank-repo/template.yaml b/examples/templates/github-blank-repo/template.yaml index 675190a..551d698 100644 --- a/examples/templates/github-blank-repo/template.yaml +++ b/examples/templates/github-blank-repo/template.yaml @@ -7,6 +7,8 @@ metadata: - recommended - example - github + - family:repo + - provider:github description: | This template creates a new blank repository in GitHub. It fetches a template from the local filesystem, and then publishes the result to a new repository. diff --git a/examples/templates/github-pull-request/template.yaml b/examples/templates/github-pull-request/template.yaml index 4790f74..b11bb94 100644 --- a/examples/templates/github-pull-request/template.yaml +++ b/examples/templates/github-pull-request/template.yaml @@ -3,6 +3,13 @@ kind: Template metadata: name: github-pull-request-docs title: GitHub Pull Request Docs + tags: + - recommended + - example + - github + - techdocs + - family:docs + - provider:github description: | This template creates a new documentation site in a GitHub repository. It fetches a template from the local filesystem, fetches some docs from diff --git a/examples/templates/gitlab-blank-repo/template.yaml b/examples/templates/gitlab-blank-repo/template.yaml index c0d1de8..4a29c0e 100644 --- a/examples/templates/gitlab-blank-repo/template.yaml +++ b/examples/templates/gitlab-blank-repo/template.yaml @@ -7,6 +7,8 @@ metadata: - recommended - example - gitlab + - family:repo + - provider:gitlab description: | This template creates a new blank repository in GitLab. It fetches a template from the local filesystem, and then publishes the result to a new repository. diff --git a/examples/templates/gitlab-merge-request/template.yaml b/examples/templates/gitlab-merge-request/template.yaml index efc3dac..eb480fe 100644 --- a/examples/templates/gitlab-merge-request/template.yaml +++ b/examples/templates/gitlab-merge-request/template.yaml @@ -3,6 +3,13 @@ kind: Template metadata: name: docs-template-gitlab-merge-request title: Documentation Template in GitLab with Merge Request + tags: + - recommended + - example + - gitlab + - techdocs + - family:docs + - provider:gitlab description: | This template creates a new documentation site in a GitLab repository. It fetches a template from the local filesystem, fetches some docs from diff --git a/examples/templates/mcp-server/template.yaml b/examples/templates/mcp-server/template.yaml new file mode 100644 index 0000000..a44900e --- /dev/null +++ b/examples/templates/mcp-server/template.yaml @@ -0,0 +1,106 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: mcp-server + title: MCP Server + tags: + - recommended + - family:ai-native + - provider:github + - ai + - mcp + description: | + Scaffold a Model Context Protocol (MCP) server: a stdio-based tool + server that AI assistants connect to for domain-specific tools and + context. Ships with one example tool, an AGENTS.md, a TechDocs site, + and catalog registration. +spec: + owner: platform-engineering + type: service + + parameters: + - title: Server details + required: + - owner + - name + properties: + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + catalogFilter: + kind: [Group, User] + name: + title: Name + type: string + description: Unique name of the MCP server + ui:placeholder: my-mcp-server + description: + title: Description + type: string + description: What tools and context this server exposes + + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + requestUserCredentials: + secretsKey: USER_OAUTH_TOKEN + allowedHosts: + - github.com + allowedOwners: + - echohello-dev + allowedOrganizations: + - echohello-dev + + steps: + - id: fetch-base + name: Fetch Base + action: fetch:template + input: + url: ./template + values: + name: ${{ parameters.name }} + owner: ${{ parameters.owner }} + destination: ${{ parameters.repoUrl | parseRepoUrl }} + description: ${{ parameters.description }} + + - id: publish + name: Publish + action: publish:github + input: + allowedHosts: + - github.com + description: ${{ parameters.description }} + repoUrl: ${{ parameters.repoUrl }} + token: ${{ secrets.USER_OAUTH_TOKEN }} + gitAuthorName: ${{ user.entity.metadata.name }} + gitAuthorEmail: ${{ user.entity.spec.profile.email }} + visibility: public + + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }} + catalogInfoPath: /catalog-info.yaml + + output: + links: + - title: Repository + url: ${{ steps.publish.output.remoteUrl }} + - title: Open in catalog + icon: catalog + entityRef: ${{ steps.register.output.entityRef }} + text: + - title: Next steps + content: | + Your MCP server skeleton is ready. Add tools in `src/index.ts` + with `server.registerTool(...)` and document them in `docs/`. diff --git a/examples/templates/mcp-server/template/AGENTS.md b/examples/templates/mcp-server/template/AGENTS.md new file mode 100644 index 0000000..19fdb7f --- /dev/null +++ b/examples/templates/mcp-server/template/AGENTS.md @@ -0,0 +1,26 @@ +# Agent instructions for ${{ values.name }} + +## What this repo is + +A Model Context Protocol (MCP) server exposing domain tools to AI +assistants over stdio. + +## Commands + +- Build: `npm run build` +- Run locally: `npm start` (after build) — speaks MCP over stdio +- Type-check without emitting: `npx tsc --noEmit` + +## Conventions + +- Every tool is registered in `src/index.ts` with `server.registerTool`. +- Tool names are `snake_case` and scoped to this server's domain. +- Tool descriptions are written for the model, not for humans: say what + the tool does, when to use it, and what it returns. +- Keep tools stateless where possible; take everything they need as input. +- No secrets in the repo; configuration comes from environment variables. + +## When editing + +- Update `docs/` when tools are added, removed, or change shape. +- Keep this file short — move detail into `docs/` and link to it. diff --git a/examples/templates/mcp-server/template/Dockerfile b/examples/templates/mcp-server/template/Dockerfile new file mode 100644 index 0000000..12c9fda --- /dev/null +++ b/examples/templates/mcp-server/template/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN npm install +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package.json ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist +CMD ["node", "dist/index.js"] diff --git a/examples/templates/mcp-server/template/README.md b/examples/templates/mcp-server/template/README.md new file mode 100644 index 0000000..753d675 --- /dev/null +++ b/examples/templates/mcp-server/template/README.md @@ -0,0 +1,36 @@ +# ${{ values.name }} + +{% if values.description %}${{ values.description }}{% else %}Model Context Protocol server.{% endif %} + +A [Model Context Protocol](https://modelcontextprotocol.io) server that AI +assistants connect to for domain-specific tools and context. + +## Quick start + +```bash +npm install +npm run build +npm start +``` + +The server speaks MCP over stdio. Register it with your assistant of +choice (Claude Desktop, Claude Code, Cursor, ...) by pointing it at +`node dist/index.js`. + +## Layout + +| Path | Purpose | +|---|---| +| `src/index.ts` | Server definition and tool registrations | +| `docs/` | TechDocs site source | +| `AGENTS.md` | Repo instructions for AI coding tools | + +## Adding a tool + +```ts +server.registerTool( + 'my_tool', + { description: 'What it does', inputSchema: { /* zod schema */ } }, + async args => ({ content: [{ type: 'text', text: 'result' }] }), +); +``` diff --git a/examples/templates/mcp-server/template/catalog-info.yaml b/examples/templates/mcp-server/template/catalog-info.yaml new file mode 100644 index 0000000..321f221 --- /dev/null +++ b/examples/templates/mcp-server/template/catalog-info.yaml @@ -0,0 +1,20 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: ${{ values.name | dump }} + description: >- + {% if values.description %} + ${{ values.description | dump }} + {% else %} + Model Context Protocol server + {% endif %} + tags: + - ai-native + - mcp + annotations: + github.com/project-slug: ${{ values.destination.owner + "/" + values.destination.repo }} + backstage.io/techdocs-ref: dir:. +spec: + type: service + lifecycle: experimental + owner: ${{ values.owner | dump }} diff --git a/examples/templates/mcp-server/template/docs/index.md b/examples/templates/mcp-server/template/docs/index.md new file mode 100644 index 0000000..e69023f --- /dev/null +++ b/examples/templates/mcp-server/template/docs/index.md @@ -0,0 +1,39 @@ +# ${{ values.name }} + +{% if values.description %}${{ values.description }}{% else %}Model Context Protocol server.{% endif %} + +## What it does + +Exposes domain tools to AI assistants over the +[Model Context Protocol](https://modelcontextprotocol.io), using stdio +transport. + +## Tools + +| Tool | Description | +|---|---| +| `ping` | Liveness check — returns server name and time | +| `echo` | Echoes back the provided text (example tool) | + +## Adding a tool + +Register tools in `src/index.ts`: + +```ts +server.registerTool( + 'my_tool', + { description: 'Written for the model: what, when, returns', inputSchema: {} }, + async args => ({ content: [{ type: 'text', text: 'result' }] }), +); +``` + +Guidelines: + +- Tool names are `snake_case`, scoped to this server's domain. +- Descriptions are written for the model, not humans. +- Prefer stateless tools; pass everything needed as input. + +## Configuration + +No configuration required for the skeleton. Add environment variables here +as tools need them — never commit secrets. diff --git a/examples/templates/mcp-server/template/mkdocs.yml b/examples/templates/mcp-server/template/mkdocs.yml new file mode 100644 index 0000000..2d9dade --- /dev/null +++ b/examples/templates/mcp-server/template/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: ${{ values.name | dump }} +{% if values.description %} +site_description: ${{ values.description | dump }} +{% endif %} + +nav: + - Overview: index.md + +plugins: + - techdocs-core diff --git a/examples/templates/mcp-server/template/package.json b/examples/templates/mcp-server/template/package.json new file mode 100644 index 0000000..dfbaea6 --- /dev/null +++ b/examples/templates/mcp-server/template/package.json @@ -0,0 +1,21 @@ +{ + "name": "${{ values.name }}", + "version": "0.1.0", + "private": true, + "description": "{% if values.description %}${{ values.description }}{% else %}Model Context Protocol server{% endif %}", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "dev": "tsc -p tsconfig.json --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.5.0" + } +} diff --git a/examples/templates/mcp-server/template/src/index.ts b/examples/templates/mcp-server/template/src/index.ts new file mode 100644 index 0000000..f652ad9 --- /dev/null +++ b/examples/templates/mcp-server/template/src/index.ts @@ -0,0 +1,48 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +const server = new McpServer({ + name: '${{ values.name }}', + version: '0.1.0', +}); + +server.registerTool( + 'ping', + { + description: + 'Check that the server is alive. Returns a pong with the server name and current time.', + inputSchema: {}, + }, + async () => ({ + content: [ + { + type: 'text', + text: `pong from ${{ values.name }} at ${new Date().toISOString()}`, + }, + ], + }), +); + +server.registerTool( + 'echo', + { + description: 'Echo back the provided text. Useful as a starting point for new tools.', + inputSchema: { + text: z.string().describe('The text to echo back'), + }, + }, + async ({ text }) => ({ + content: [{ type: 'text', text }], + }), +); + +async function main(): Promise { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch(error => { + console.error('server failed to start', error); + process.exit(1); +}); diff --git a/examples/templates/mcp-server/template/tsconfig.json b/examples/templates/mcp-server/template/tsconfig.json new file mode 100644 index 0000000..10c9723 --- /dev/null +++ b/examples/templates/mcp-server/template/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/app/src/components/home/HomePage.tsx b/packages/app/src/components/home/HomePage.tsx index b1c963e..cdda651 100644 --- a/packages/app/src/components/home/HomePage.tsx +++ b/packages/app/src/components/home/HomePage.tsx @@ -18,6 +18,7 @@ import TrendingUpIcon from '@mui/icons-material/TrendingUp'; import CodeIcon from '@mui/icons-material/Code'; import GroupsIcon from '@mui/icons-material/Groups'; import AnnouncementIcon from '@mui/icons-material/Announcement'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useState, useEffect, ReactNode } from 'react'; import Box from '@mui/material/Box'; @@ -40,8 +41,6 @@ import ListItemButton from '@mui/material/ListItemButton'; import Avatar from '@mui/material/Avatar'; import Paper from '@mui/material/Paper'; import Divider from '@mui/material/Divider'; -import Alert from '@mui/material/Alert'; -import AlertTitle from '@mui/material/AlertTitle'; import LinearProgress from '@mui/material/LinearProgress'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; @@ -122,6 +121,15 @@ interface Template { tags: string[]; } +interface OnboardingDoc { + name: string; + title: string; + description: string; + kind: string; + namespace: string; + tags: string[]; +} + interface HealthStatus { healthy: number; warning: number; @@ -174,6 +182,7 @@ export const HomePage = () => { }); const [recentEntities, setRecentEntities] = useState([]); const [templates, setTemplates] = useState([]); + const [onboardingDocs, setOnboardingDocs] = useState([]); const [healthStatus, setHealthStatus] = useState({ healthy: 0, warning: 0, @@ -182,6 +191,7 @@ export const HomePage = () => { }); const [loadingRecent, setLoadingRecent] = useState(true); const [loadingTemplates, setLoadingTemplates] = useState(true); + const [loadingDocs, setLoadingDocs] = useState(true); useEffect(() => { const fetchAllData = async () => { @@ -255,7 +265,7 @@ export const HomePage = () => { setLoadingRecent(false); const templateResponse = await catalogApi.getEntities({ - filter: [{ kind: 'template' }], + filter: [{ kind: 'template', 'metadata.tags': 'recommended' }], }); const templateList = templateResponse.items .slice(0, 4) @@ -267,10 +277,28 @@ export const HomePage = () => { })); setTemplates(templateList); setLoadingTemplates(false); + + const docsResponse = await catalogApi.getEntities({ + filter: [ + { kind: 'component', 'metadata.tags': 'onboarding' }, + { kind: 'component', 'metadata.tags': 'getting-started' }, + ], + }); + const docsList = docsResponse.items.slice(0, 4).map((e: Entity) => ({ + name: e.metadata.name, + title: e.metadata.title || e.metadata.name, + description: e.metadata.description || '', + kind: e.kind.toLowerCase(), + namespace: e.metadata.namespace || 'default', + tags: (e.metadata.tags as string[]) || [], + })); + setOnboardingDocs(docsList); + setLoadingDocs(false); } catch (err) { setStats(prev => ({ ...prev, loading: false })); setLoadingRecent(false); setLoadingTemplates(false); + setLoadingDocs(false); } }; @@ -289,24 +317,15 @@ export const HomePage = () => { } }; - const quickActions = [ + const startHereCards = [ { icon: ( ), - title: 'Create New', - description: 'Scaffold a new project from templates', + title: 'Create your first service', + description: 'Scaffold a new project from a curated template', path: '/self-service', - }, - { - icon: ( - - ), - title: 'Register Existing', - description: 'Import an existing component to the catalog', - path: '/catalog-import', + ariaLabel: 'Create your first service from a software template', }, { icon: ( @@ -314,20 +333,29 @@ export const HomePage = () => { sx={{ fontSize: 40, color: theme.palette.info.main }} /> ), - title: 'Browse Docs', - description: 'Explore technical documentation', + title: 'Browse the docs', + description: 'Read the onboarding and platform documentation', path: '/docs', + ariaLabel: 'Browse the documentation index', }, { icon: ( - + ), - title: 'API Explorer', - description: 'Discover and explore available APIs', - path: '/api-docs', + title: 'Register an existing repo', + description: 'Import an existing component into the catalog', + path: '/catalog-import', + ariaLabel: 'Register an existing repository in the catalog', }, ]; + const supportDoc = onboardingDocs.find(d => d.tags.includes('support')); + const supportPath = supportDoc + ? `/docs/${supportDoc.namespace}/${supportDoc.kind}/${supportDoc.name}` + : '/docs'; + const catalogItems = [ { icon: , @@ -441,14 +469,29 @@ export const HomePage = () => { {template.description} - {template.tags.slice(0, 3).map(tag => ( - - ))} + {template.tags + .filter(tag => tag.startsWith('family:')) + .map(tag => ( + + ))} + {template.tags + .filter(tag => !tag.startsWith('family:')) + .slice(0, 3) + .map(tag => ( + + ))} @@ -474,6 +517,88 @@ export const HomePage = () => { ); } + let onboardingDocsContent: ReactNode; + if (loadingDocs) { + onboardingDocsContent = ( + + {[1, 2, 3, 4].map(i => ( + + + + ))} + + ); + } else if (onboardingDocs.length > 0) { + onboardingDocsContent = ( + + {onboardingDocs.map(doc => ( + + + navigate(`/docs/${doc.namespace}/${doc.kind}/${doc.name}`) + } + > + + + {doc.title} + + + {doc.description} + + + {doc.tags.slice(0, 3).map(tag => ( + + ))} + + + + + ))} + + ); + } else { + onboardingDocsContent = ( + + + + No onboarding docs tagged yet. Tag a docs entity with{' '} + + onboarding + {' '} + to surface it here. + + + ); + } + let recentEntitiesContent: ReactNode; if (loadingRecent) { recentEntitiesContent = ( @@ -587,34 +712,6 @@ export const HomePage = () => { /> - - } - action={ - - } - > - Getting Started? - Check out our{' '} - navigate('/docs')} - > - documentation - {' '} - to learn how to create your first service and register it in the - catalog. - - - { sx={{ fontSize: 28, color: theme.palette.primary.main }} /> } - title="Quick Actions" + title="Start here" /> - {quickActions.map(action => ( - + {startHereCards.map(action => ( + navigate(action.path)} + aria-label={action.ariaLabel} sx={{ height: '100%', p: 3 }} > { + + + } + title="Onboarding Docs" + action={ + + } + /> + {onboardingDocsContent} + + { } /> @@ -937,118 +1057,68 @@ export const HomePage = () => { } - title="Quick Links" + title="Support & Reference" /> - navigate('/scorecard')}> + navigate('/docs')} + aria-label="Open the TechDocs documentation index" + > - + - navigate('/explore')}> + navigate('/api-docs')} + aria-label="Open the API explorer" + > - + - navigate('/catalog-graph')}> + navigate('/notifications')} + aria-label="Open notifications and system alerts" + > - + - navigate('/notifications')}> + navigate(supportPath)} + aria-label="Get help and support documentation" + > - + - - - - } - title="Getting Started" - /> - - - - New to the Developer Portal? Here are some helpful - resources: - - - - - - - - - -