diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..f25034b5 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +node_modules +.wrangler \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..e41eaa81 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,41 @@ +{ + "root": true, + "ignorePatterns": ["**/*"], + "plugins": ["@nx"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": { + "@nx/enforce-module-boundaries": [ + "error", + { + "enforceBuildableLibDependency": true, + "allow": [], + "depConstraints": [ + { + "sourceTag": "*", + "onlyDependOnLibsWithTags": ["*"] + } + ] + } + ] + } + }, + { + "files": ["*.ts", "*.tsx"], + "extends": ["plugin:@nx/typescript"], + "rules": { + "@typescript-eslint/no-extra-semi": "error", + "no-extra-semi": "off" + } + }, + { + "files": ["*.js", "*.jsx"], + "extends": ["plugin:@nx/javascript"], + "rules": { + "@typescript-eslint/no-extra-semi": "error", + "no-extra-semi": "off" + } + } + ] +} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 00000000..c882f027 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,67 @@ +on: + pull_request: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + name: Build + steps: + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: pnpm install --frozen-lockfile + - run: pnpm nx affected --targets=build --base=origin/main --head=HEAD + test: + runs-on: ubuntu-latest + name: Unit tests + steps: + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: pnpm install --frozen-lockfile + - run: pnpm nx affected --targets=test --base=origin/main --head=HEAD --passWithNoTests --watch=false + static-analysis: + runs-on: ubuntu-latest + name: Static Analyzis + steps: + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: pnpm install --frozen-lockfile + - run: pnpm nx affected --targets=lint --base=origin/main --head=HEAD + - run: NX_BASE=origin/main NX_HEAD=HEAD ./scripts/typecheck.js + - run: NX_BASE=origin/main NX_HEAD=HEAD pnpm nx format:check + e2e: + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + name: E2E tests + env: + VERSION: ${{ github.sha }} + steps: + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: pnpm install --frozen-lockfile + - run: npx playwright install --with-deps + - name: E2E + run: pnpm nx affected --targets=e2e --base=origin/main --head=HEAD + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: dist/.playwright/ + retention-days: 3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..ca083c8b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +on: + push: + tags: + - 'v**' +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + name: Release + steps: + - uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: pnpm install --frozen-lockfile + - run: pnpm nx build web-ui --skip-nx-cache + - run: cd dist/apps/web-ui && zip -r ../../../ui-${{ github.ref_name }}.zip . + - name: Create a release + uses: ncipollo/release-action@v1 + with: + artifacts: ui-${{ github.ref_name }}.zip + generateReleaseNotes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..04646382 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +dist +tmp +/out-tsc + +# dependencies +node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +.nx/cache +.nx/workspace-data +/.editorconfig +/.vscode +/db.json +.wrangler +.nx +**/playwright/.auth +test-results diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..e9b1361e --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +strict-peer-dependencies=false +auto-install-peers=true +enable-modules-dir=true +ignore-dep-scripts=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..e26f0b3f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# Add files here to ignore them from prettier formatting +/dist +/coverage +/.nx/cache +/.nx/workspace-data \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..544138be --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/@types/global-env.d.ts b/@types/global-env.d.ts new file mode 100644 index 00000000..81c972c5 --- /dev/null +++ b/@types/global-env.d.ts @@ -0,0 +1,8 @@ +interface Env { + VERSION: string; + [key: string]: string & {}; +} + +declare module globalThis { + var env: Env; +} diff --git a/@types/vite.d.ts b/@types/vite.d.ts new file mode 100644 index 00000000..302db698 --- /dev/null +++ b/@types/vite.d.ts @@ -0,0 +1,4 @@ +declare module '*?url' { + const content: string; + export default content; +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..5bc4716e --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Restate Web UI + +This repository is for Restate Web UI. + +## Getting started + +- This repository uses [`pnpm`](https://pnpm.io) as the package manager. If you are not contributing to the project, you do not need to install `pnpm`. + +```sh +# Install dependencies +pnpm install +``` + +- This repository utilizes [`nx`](https://nx.dev) for the monorepo structure. Each package within the monorepo has multiple targets. To run a target for a package, use commands like: + +```sh +# pnpm nx <...options> + +# Run the web ui app in dev mode with mock configuration +pnpm nx serve web-ui --configuration=mock + +# Run the ui-button unit tests in watch mode +pnpm nx run test ui-button --watch +``` + +Details of each package's targets can be available in the `project.json` file within each package. + +## Important Packages + +- [Web UI App](apps/web-ui/README.md) +- [Admin Api Client](libs/data-access/admin-api/README.md) diff --git a/apps/mock-admin-api/.eslintrc.json b/apps/mock-admin-api/.eslintrc.json new file mode 100644 index 00000000..9d9c0db5 --- /dev/null +++ b/apps/mock-admin-api/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/mock-admin-api/project.json b/apps/mock-admin-api/project.json new file mode 100644 index 00000000..63c14f41 --- /dev/null +++ b/apps/mock-admin-api/project.json @@ -0,0 +1,65 @@ +{ + "name": "mock-admin-api", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/mock-admin-api/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "platform": "node", + "outputPath": "dist/apps/mock-admin-api", + "format": ["cjs"], + "bundle": true, + "main": "apps/mock-admin-api/src/main.ts", + "tsConfig": "apps/mock-admin-api/tsconfig.app.json", + "assets": ["apps/mock-admin-api/src/assets"], + "generatePackageJson": true, + "esbuildOptions": { + "sourcemap": true, + "outExtension": { + ".js": ".js" + } + } + }, + "configurations": { + "proxy": { + "main": "apps/mock-admin-api/src/proxy.ts" + }, + "development": {}, + "production": { + "esbuildOptions": { + "sourcemap": false, + "outExtension": { + ".js": ".js" + } + } + } + } + }, + "serve": { + "executor": "@nx/js:node", + "defaultConfiguration": "development", + "dependsOn": ["build"], + "options": { + "port": 0, + "buildTarget": "mock-admin-api:build", + "runBuildTargetDependencies": false + }, + "configurations": { + "development": { + "buildTarget": "mock-admin-api:build:development" + }, + "proxy": { + "buildTarget": "mock-admin-api:build:proxy" + }, + "production": { + "buildTarget": "mock-admin-api:build:production" + } + } + } + } +} diff --git a/apps/mock-admin-api/src/main.ts b/apps/mock-admin-api/src/main.ts new file mode 100644 index 00000000..9b798178 --- /dev/null +++ b/apps/mock-admin-api/src/main.ts @@ -0,0 +1,15 @@ +import { adminApiMockHandlers } from '@restate/data-access/admin-api-fixtures'; + +const port = process.env.PORT ? Number(process.env.PORT) : 4001; + +import { createMiddleware } from '@mswjs/http-middleware'; +import express from 'express'; +import cors from 'cors'; + +const app = express(); + +app.use(cors()); +app.use(express.json()); +app.use(createMiddleware(...adminApiMockHandlers)); + +app.listen(port); diff --git a/apps/mock-admin-api/src/proxy.ts b/apps/mock-admin-api/src/proxy.ts new file mode 100644 index 00000000..b81b1fc0 --- /dev/null +++ b/apps/mock-admin-api/src/proxy.ts @@ -0,0 +1,38 @@ +const port = process.env.PORT ? Number(process.env.PORT) : 4001; +const ADMIN_ENDPOINT = process.env.ADMIN_ENDPOINT ?? 'http://localhost:9070'; +import express, { RequestHandler } from 'express'; +import cors from 'cors'; + +const app = express(); + +app.use(cors()); +app.use(express.json()); + +const proxyHandler: RequestHandler = async (req, res) => { + const response = await fetch(`${ADMIN_ENDPOINT}${req.url}`, { + method: req.method, + headers: new Headers(req.headers as Record), + ...(req.body && + ['POST', 'PUT'].includes(req.method) && { + body: JSON.stringify(req.body), + }), + }); + + response.body?.pipeTo( + new WritableStream({ + start() { + res.statusCode = response.status; + response.headers.forEach((v, n) => res.setHeader(n, v)); + }, + write(chunk) { + res.write(chunk); + }, + close() { + res.end(); + }, + }) + ); +}; + +app.all('*', proxyHandler); +app.listen(port); diff --git a/apps/mock-admin-api/tsconfig.app.json b/apps/mock-admin-api/tsconfig.app.json new file mode 100644 index 00000000..f5e2e085 --- /dev/null +++ b/apps/mock-admin-api/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["node"] + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/apps/mock-admin-api/tsconfig.json b/apps/mock-admin-api/tsconfig.json new file mode 100644 index 00000000..baefbabd --- /dev/null +++ b/apps/mock-admin-api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ], + "compilerOptions": { + "esModuleInterop": true, + "resolveJsonModule": true + } +} diff --git a/apps/web-ui-e2e/.eslintrc.json b/apps/web-ui-e2e/.eslintrc.json new file mode 100644 index 00000000..fbf2c975 --- /dev/null +++ b/apps/web-ui-e2e/.eslintrc.json @@ -0,0 +1,22 @@ +{ + "extends": ["plugin:playwright/recommended", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["src/**/*.{ts,js,tsx,jsx}"], + "rules": {} + } + ] +} diff --git a/apps/web-ui-e2e/playwright.config.ts b/apps/web-ui-e2e/playwright.config.ts new file mode 100644 index 00000000..750f25e8 --- /dev/null +++ b/apps/web-ui-e2e/playwright.config.ts @@ -0,0 +1,70 @@ +import { defineConfig, devices } from '@playwright/test'; +import { nxE2EPreset } from '@nx/playwright/preset'; + +import { workspaceRoot } from '@nx/devkit'; + +// For CI, you may want to set BASE_URL to the deployed application. +const baseURL = process.env['BASE_URL'] || 'http://localhost:4300'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...nxE2EPreset(__filename, { testDir: './src' }), + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, + /* Run your local dev server before starting the tests */ + webServer: { + command: 'SCENARIO=E2E pnpm exec nx serve web-ui -c mock', + reuseExistingServer: !process.env.CI, + cwd: workspaceRoot, + url: 'http://localhost:4001/version', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + // Uncomment for mobile browsers support + /* { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, */ + + // Uncomment for branded browsers + /* { + name: 'Microsoft Edge', + use: { ...devices['Desktop Edge'], channel: 'msedge' }, + }, + { + name: 'Google Chrome', + use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + } */ + ], +}); diff --git a/apps/web-ui-e2e/project.json b/apps/web-ui-e2e/project.json new file mode 100644 index 00000000..df8740a9 --- /dev/null +++ b/apps/web-ui-e2e/project.json @@ -0,0 +1,10 @@ +{ + "name": "web-ui-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/web-ui-e2e/src", + "tags": [], + "implicitDependencies": ["web-ui"], + "// targets": "to see all targets run: nx show project web-ui-e2e --web", + "targets": {} +} diff --git a/apps/web-ui-e2e/src/example.spec.ts b/apps/web-ui-e2e/src/example.spec.ts new file mode 100644 index 00000000..bf820c2a --- /dev/null +++ b/apps/web-ui-e2e/src/example.spec.ts @@ -0,0 +1,9 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + const listDeployment = page.waitForResponse(`**/deployments`); + await page.goto('/'); + await listDeployment; + // Expect h3 to contain a substring. + expect(await page.locator('h3').innerText()).toContain('No deployment'); +}); diff --git a/apps/web-ui-e2e/tsconfig.json b/apps/web-ui-e2e/tsconfig.json new file mode 100644 index 00000000..114364a1 --- /dev/null +++ b/apps/web-ui-e2e/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "sourceMap": false + }, + "include": [ + "**/*.ts", + "**/*.js", + "playwright.config.ts", + "src/**/*.spec.ts", + "src/**/*.spec.js", + "src/**/*.test.ts", + "src/**/*.test.js", + "src/**/*.d.ts" + ] +} diff --git a/apps/web-ui/.eslintignore b/apps/web-ui/.eslintignore new file mode 100644 index 00000000..bce5a5fb --- /dev/null +++ b/apps/web-ui/.eslintignore @@ -0,0 +1,2 @@ +build +public/build \ No newline at end of file diff --git a/apps/web-ui/.eslintrc.json b/apps/web-ui/.eslintrc.json new file mode 100644 index 00000000..9d9c0db5 --- /dev/null +++ b/apps/web-ui/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/web-ui/.gitignore b/apps/web-ui/.gitignore new file mode 100644 index 00000000..9ca4842f --- /dev/null +++ b/apps/web-ui/.gitignore @@ -0,0 +1,4 @@ +.cache +build +public/build +.env diff --git a/apps/web-ui/README.md b/apps/web-ui/README.md new file mode 100644 index 00000000..14bd18dd --- /dev/null +++ b/apps/web-ui/README.md @@ -0,0 +1,22 @@ +# Web UI App + +This application is for Restate Web UI, developed using [`Remix`](https://remix.run/), and served as a [SPA](https://remix.run/docs/en/main/guides/spa-mode). + +### Commands + +```sh +# Run Web UI app in dev mode +pnpm nx serve web-ui -c mock + +# Build Web UI app in prod mode +pnpm nx build web-ui + +# Start the Web UI app in prod mode +pnpm nx start web-ui -c mock|dev|prod + +# Run unit tests for Web UI app +pnpm nx test web-ui + +# Run end-to-end tests for Web UI app +pnpm nx e2e web-ui-e2e +``` diff --git a/apps/web-ui/app/entry.client.tsx b/apps/web-ui/app/entry.client.tsx new file mode 100644 index 00000000..afdfc0ce --- /dev/null +++ b/apps/web-ui/app/entry.client.tsx @@ -0,0 +1,12 @@ +import { RemixBrowser } from '@remix-run/react'; +import { startTransition, StrictMode } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/apps/web-ui/app/entry.server.tsx b/apps/web-ui/app/entry.server.tsx new file mode 100644 index 00000000..f65553dc --- /dev/null +++ b/apps/web-ui/app/entry.server.tsx @@ -0,0 +1,19 @@ +import type { EntryContext } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import { renderToString } from 'react-dom/server'; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + let html = renderToString( + + ); + html = '\n' + html; + return new Response(html, { + headers: { 'Content-Type': 'text/html' }, + status: responseStatusCode, + }); +} diff --git a/apps/web-ui/app/root.tsx b/apps/web-ui/app/root.tsx new file mode 100644 index 00000000..aa89ec5e --- /dev/null +++ b/apps/web-ui/app/root.tsx @@ -0,0 +1,196 @@ +import { + Links, + Meta, + Outlet, + Path, + Scripts, + ScrollRestoration, + useNavigate, +} from '@remix-run/react'; +import styles from './tailwind.css?url'; +import type { LinksFunction } from '@remix-run/node'; +import { LayoutOutlet, LayoutProvider, LayoutZone } from '@restate/ui/layout'; +import { RouterProvider } from 'react-aria-components'; +import { Button, Spinner } from '@restate/ui/button'; +import { useCallback } from 'react'; +import { QueryProvider } from '@restate/util/react-query'; +import { + AdminBaseURLProvider, + useVersion, +} from '@restate/data-access/admin-api'; +import { Nav, NavItem } from '@restate/ui/nav'; +import { Icon, IconName } from '@restate/ui/icons'; +import { tv } from 'tailwind-variants'; + +export const links: LinksFunction = () => [ + { + rel: 'preconnect', + href: 'https://rsms.me/', + }, + { rel: 'stylesheet', href: styles }, + { rel: 'stylesheet', href: 'https://rsms.me/inter/inter.css' }, + { rel: 'apple-touch-icon', href: '/apple-touch-icon.png', sizes: '180x180' }, + { + rel: 'icon', + type: 'image/png', + href: '/favicon-32x32.png', + sizes: '32x32', + }, + { + rel: 'icon', + type: 'image/png', + href: '/favicon-16x16.png', + sizes: '16x16', + }, + { rel: 'manifest', href: '/site.webmanifest' }, + { rel: 'mask-icon', href: '/safari-pinned-tab.svg', color: '#222452' }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + const remixNavigate = useNavigate(); + + const navigate = useCallback( + (to: string | Partial) => { + remixNavigate(to, { preventScrollReset: true }); + }, + [remixNavigate] + ); + + return ( + + + + + + + + Restate UI + + + + + + {children} + + + + + + ); +} + +const miniStyles = tv({ + base: '', + slots: { + container: 'relative w-3 h-3 text-xs', + icon: 'absolute left-0 top-[1px] w-3 h-3 stroke-0 fill-current', + animation: + 'absolute inset-left-0 top-[1px] w-3 h-3 stroke-[4px] fill-current opacity-20', + }, + variants: { + status: { + PENDING: { + container: 'text-yellow-500', + animation: 'animate-ping', + }, + DEGRADED: { + container: 'text-yellow-500', + animation: 'animate-ping', + }, + ACTIVE: { container: 'text-green-500', animation: 'animate-ping' }, + HEALTHY: { container: 'text-green-500', animation: 'animate-ping' }, + FAILED: { container: 'text-red-500', animation: 'animate-ping' }, + DELETED: { container: 'text-gray-400', animation: 'hidden' }, + }, + }, +}); +// TODO +function Version() { + const { data } = useVersion(); + + if (!data?.version) { + return null; + } + + return ( + + v{data?.version} + + ); +} + +function getCookieValue(name: string) { + const cookies = document.cookie + .split(';') + .map((cookie) => cookie.trim().split('=')); + const cookieValue = cookies.find(([key]) => key === name)?.at(1); + return cookieValue ? decodeURIComponent(cookieValue) : null; +} + +export default function App() { + const { container, icon, animation } = miniStyles(); + + return ( + + + + + + +
+
+ +
+ + + + +
+
+
+
+ ); +} + +// TODO: implement proper loader +export function HydrateFallback() { + return ( +

+ + Loading... +

+ ); +} diff --git a/apps/web-ui/app/routes/_index.tsx b/apps/web-ui/app/routes/_index.tsx new file mode 100644 index 00000000..19fb55a9 --- /dev/null +++ b/apps/web-ui/app/routes/_index.tsx @@ -0,0 +1,4 @@ +import { redirect } from '@remix-run/react'; + +export const clientLoader = () => redirect('/overview'); +export default () => null; diff --git a/apps/web-ui/app/routes/overview.tsx b/apps/web-ui/app/routes/overview.tsx new file mode 100644 index 00000000..04d83336 --- /dev/null +++ b/apps/web-ui/app/routes/overview.tsx @@ -0,0 +1,3 @@ +import { overview } from '@restate/features/overview-route'; + +export default overview.Component; diff --git a/apps/web-ui/app/tailwind.css b/apps/web-ui/app/tailwind.css new file mode 100644 index 00000000..a6ecf8d0 --- /dev/null +++ b/apps/web-ui/app/tailwind.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-family: 'Inter var', 'Helvetica', system-ui, sans-serif; + } +} diff --git a/apps/web-ui/package.json b/apps/web-ui/package.json new file mode 100644 index 00000000..55b8032b --- /dev/null +++ b/apps/web-ui/package.json @@ -0,0 +1,27 @@ +{ + "private": true, + "name": "web-ui", + "description": "", + "license": "", + "scripts": {}, + "type": "module", + "dependencies": { + "@remix-run/node": "^2.8.1", + "@remix-run/react": "^2.8.1", + "@remix-run/serve": "^2.8.1", + "isbot": "^4.4.0", + "react": "19.0.0-rc-65903583-20240805", + "react-dom": "19.0.0-rc-65903583-20240805" + }, + "devDependencies": { + "@remix-run/dev": "^2.8.1", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "eslint": "^8.56.0", + "typescript": "~5.5.2" + }, + "engines": { + "node": ">=14" + }, + "sideEffects": false +} diff --git a/apps/web-ui/postcss.config.cjs b/apps/web-ui/postcss.config.cjs new file mode 100644 index 00000000..8d55d169 --- /dev/null +++ b/apps/web-ui/postcss.config.cjs @@ -0,0 +1,15 @@ +const { join } = require('path'); + +// Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build +// option from your application's configuration (i.e. project.json). +// +// See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries + +module.exports = { + plugins: { + tailwindcss: { + config: join(__dirname, 'tailwind.config.cjs'), + }, + autoprefixer: {}, + }, +}; diff --git a/apps/web-ui/project.json b/apps/web-ui/project.json new file mode 100644 index 00000000..dfeeb452 --- /dev/null +++ b/apps/web-ui/project.json @@ -0,0 +1,63 @@ +{ + "name": "web-ui", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/web-ui", + "projectType": "application", + "tags": [], + "// targets": "to see all targets run: nx show project web-ui --web", + "targets": { + "serve": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "cd apps/web-ui && ../../node_modules/.bin/remix vite:dev --port=4300" + } + ] + }, + "configurations": { + "mock": { + "commands": [ + { + "command": "nx serve mock-admin-api" + }, + { + "command": "cd apps/web-ui && ADMIN_BASE_URL=http://localhost:4001 ../../node_modules/.bin/remix vite:dev --port=4300" + } + ], + "parallel": true + }, + "local": { + "commands": [ + { + "command": "nx serve mock-admin-api -c proxy" + }, + { + "command": "cd apps/web-ui && ADMIN_BASE_URL=http://localhost:4001 ../../node_modules/.bin/remix vite:dev --port=4300" + } + ], + "parallel": true + } + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "rm -R -f dist/apps/web-ui", + "cd apps/web-ui && ../../node_modules/.bin/remix vite:build", + "mkdir -p dist/apps/web-ui", + "mv apps/web-ui/build/client/* dist/apps/web-ui", + "rm -R apps/web-ui/build" + ], + "parallel": false + } + }, + "start": { + "executor": "nx:run-commands", + "options": { + "command": "cd apps/web-ui && ../../node_modules/.bin/remix vite:build && ../../node_modules/.bin/vite preview --port=4300" + } + } + } +} diff --git a/apps/web-ui/public/android-chrome-192x192.png b/apps/web-ui/public/android-chrome-192x192.png new file mode 100644 index 00000000..a5790c0e Binary files /dev/null and b/apps/web-ui/public/android-chrome-192x192.png differ diff --git a/apps/web-ui/public/android-chrome-512x512.png b/apps/web-ui/public/android-chrome-512x512.png new file mode 100644 index 00000000..9c328c7e Binary files /dev/null and b/apps/web-ui/public/android-chrome-512x512.png differ diff --git a/apps/web-ui/public/apple-touch-icon.png b/apps/web-ui/public/apple-touch-icon.png new file mode 100644 index 00000000..286183ab Binary files /dev/null and b/apps/web-ui/public/apple-touch-icon.png differ diff --git a/apps/web-ui/public/browserconfig.xml b/apps/web-ui/public/browserconfig.xml new file mode 100644 index 00000000..4e9d6286 --- /dev/null +++ b/apps/web-ui/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #222452 + + + diff --git a/apps/web-ui/public/favicon-16x16.png b/apps/web-ui/public/favicon-16x16.png new file mode 100644 index 00000000..f29ac029 Binary files /dev/null and b/apps/web-ui/public/favicon-16x16.png differ diff --git a/apps/web-ui/public/favicon-32x32.png b/apps/web-ui/public/favicon-32x32.png new file mode 100644 index 00000000..ff8fd256 Binary files /dev/null and b/apps/web-ui/public/favicon-32x32.png differ diff --git a/apps/web-ui/public/favicon.ico b/apps/web-ui/public/favicon.ico new file mode 100644 index 00000000..0484f85b Binary files /dev/null and b/apps/web-ui/public/favicon.ico differ diff --git a/apps/web-ui/public/mstile-144x144.png b/apps/web-ui/public/mstile-144x144.png new file mode 100644 index 00000000..6719c434 Binary files /dev/null and b/apps/web-ui/public/mstile-144x144.png differ diff --git a/apps/web-ui/public/mstile-150x150.png b/apps/web-ui/public/mstile-150x150.png new file mode 100644 index 00000000..0b52f945 Binary files /dev/null and b/apps/web-ui/public/mstile-150x150.png differ diff --git a/apps/web-ui/public/mstile-310x150.png b/apps/web-ui/public/mstile-310x150.png new file mode 100644 index 00000000..df4fde09 Binary files /dev/null and b/apps/web-ui/public/mstile-310x150.png differ diff --git a/apps/web-ui/public/mstile-310x310.png b/apps/web-ui/public/mstile-310x310.png new file mode 100644 index 00000000..4e2c6bb9 Binary files /dev/null and b/apps/web-ui/public/mstile-310x310.png differ diff --git a/apps/web-ui/public/mstile-70x70.png b/apps/web-ui/public/mstile-70x70.png new file mode 100644 index 00000000..320accce Binary files /dev/null and b/apps/web-ui/public/mstile-70x70.png differ diff --git a/apps/web-ui/public/safari-pinned-tab.svg b/apps/web-ui/public/safari-pinned-tab.svg new file mode 100644 index 00000000..b21d25be --- /dev/null +++ b/apps/web-ui/public/safari-pinned-tab.svg @@ -0,0 +1,33 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + diff --git a/apps/web-ui/public/site.webmanifest b/apps/web-ui/public/site.webmanifest new file mode 100644 index 00000000..fa99de77 --- /dev/null +++ b/apps/web-ui/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/web-ui/remix.env.d.ts b/apps/web-ui/remix.env.d.ts new file mode 100644 index 00000000..dcf8c45e --- /dev/null +++ b/apps/web-ui/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/apps/web-ui/tailwind.config.cjs b/apps/web-ui/tailwind.config.cjs new file mode 100644 index 00000000..80e49b5f --- /dev/null +++ b/apps/web-ui/tailwind.config.cjs @@ -0,0 +1,36 @@ +const { createGlobPatternsForDependencies } = require('@nx/react/tailwind'); +const { join } = require('path'); +const defaultTheme = require('tailwindcss/defaultTheme'); +const { withTV } = require('tailwind-variants/transformer'); + +/** @type {import('tailwindcss').Config} */ +module.exports = withTV({ + content: [ + join( + __dirname, + '{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}' + ), + ...createGlobPatternsForDependencies(__dirname), + ], + darkMode: 'selector', + theme: { + extend: { + fontFamily: { + sans: ['Inter var', ...defaultTheme.fontFamily.sans], + }, + fontSize: { + '2xs': '0.6875rem', + code: '0.8125rem', + }, + screens: { + '3xl': '1850px', + }, + }, + }, + plugins: [ + require('tailwindcss-react-aria-components'), + require('tailwindcss-animate'), + require('@tailwindcss/forms'), + require('@tailwindcss/container-queries'), + ], +}); diff --git a/apps/web-ui/test-setup.ts b/apps/web-ui/test-setup.ts new file mode 100644 index 00000000..85205829 --- /dev/null +++ b/apps/web-ui/test-setup.ts @@ -0,0 +1,3 @@ +import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); diff --git a/apps/web-ui/tests/routes/_index.spec.tsx b/apps/web-ui/tests/routes/_index.spec.tsx new file mode 100644 index 00000000..c2d95c75 --- /dev/null +++ b/apps/web-ui/tests/routes/_index.spec.tsx @@ -0,0 +1,21 @@ +import { createRemixStub } from '@remix-run/testing'; +import { render, screen, waitFor } from '@testing-library/react'; +import Index, { clientLoader } from '../../app/routes/_index'; + +test('renders loader data', async () => { + const RemixStub = createRemixStub([ + { + path: '/', + Component: Index, + loader: clientLoader, + }, + { + path: '/overview', + Component: () =>

Overview

, + }, + ]); + + render(); + + await waitFor(() => screen.findByText('Overview')); +}); diff --git a/apps/web-ui/tsconfig.app.json b/apps/web-ui/tsconfig.app.json new file mode 100644 index 00000000..fb96972f --- /dev/null +++ b/apps/web-ui/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "../../@types/vite.d.ts", + "../../@types/load-context.d.ts", + "../../@types/global-env.d.ts" + ] + }, + "include": [ + "remix.env.d.ts", + "app/**/*.ts", + "app/**/*.tsx", + "app/**/*.js", + "app/**/*.jsx" + ], + "exclude": [ + "tests/**/*.spec.ts", + "tests/**/*.test.ts", + "tests/**/*.spec.tsx", + "tests/**/*.test.tsx", + "tests/**/*.spec.js", + "tests/**/*.test.js", + "tests/**/*.spec.jsx", + "tests/**/*.test.jsx" + ] +} diff --git a/apps/web-ui/tsconfig.json b/apps/web-ui/tsconfig.json new file mode 100644 index 00000000..d95d0312 --- /dev/null +++ b/apps/web-ui/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/web-ui/tsconfig.spec.json b/apps/web-ui/tsconfig.spec.json new file mode 100644 index 00000000..9b53fa8b --- /dev/null +++ b/apps/web-ui/tsconfig.spec.json @@ -0,0 +1,30 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest", + "../../@types/global-env.d.ts" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "app/**/*.ts", + "app/**/*.tsx", + "app/**/*.js", + "app/**/*.jsx", + "tests/**/*.spec.ts", + "tests/**/*.test.ts", + "tests/**/*.spec.tsx", + "tests/**/*.test.tsx", + "tests/**/*.spec.js", + "tests/**/*.test.js", + "tests/**/*.spec.jsx", + "tests/**/*.test.jsx" + ] +} diff --git a/apps/web-ui/vite.config.ts b/apps/web-ui/vite.config.ts new file mode 100644 index 00000000..d833bbce --- /dev/null +++ b/apps/web-ui/vite.config.ts @@ -0,0 +1,69 @@ +import { vitePlugin as remix } from '@remix-run/dev'; +import { defineConfig, loadEnv } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +const BASE_URL = '/ui/'; +const ADMIN_BASE_URL = process.env['ADMIN_BASE_URL'] || ''; +const SERVER_HEADERS = { + 'Set-Cookie': `adminBaseUrl=${ADMIN_BASE_URL}; SameSite=Strict; Path=/`, +}; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + + return { + base: BASE_URL, + root: __dirname, + cacheDir: '../../node_modules/.vite/apps/web-ui', + plugins: [ + !process.env.VITEST && + remix({ + ssr: false, + basename: BASE_URL, + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + }, + }), + nxViteTsPaths(), + ], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + server: { + headers: SERVER_HEADERS, + hmr: { + protocol: 'ws', + port: 3001, + }, + }, + preview: { + headers: SERVER_HEADERS, + }, + define: { + 'globalThis.env': { + VERSION: env.VERSION ?? 'dev', + }, + }, + + test: { + setupFiles: ['test-setup.ts'], + globals: true, + cache: { + dir: '../../node_modules/.vitest', + }, + environment: 'jsdom', + include: ['./tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + + reporters: ['default'], + coverage: { + reportsDirectory: '../../coverage/apps/web-ui', + provider: 'v8', + }, + }, + }; +}); diff --git a/libs/data-access/admin-api-fixtures/.babelrc b/libs/data-access/admin-api-fixtures/.babelrc new file mode 100644 index 00000000..fd4cbcde --- /dev/null +++ b/libs/data-access/admin-api-fixtures/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@nx/js/babel", + { + "useBuiltIns": "usage" + } + ] + ] +} diff --git a/libs/data-access/admin-api-fixtures/.eslintrc.json b/libs/data-access/admin-api-fixtures/.eslintrc.json new file mode 100644 index 00000000..3456be9b --- /dev/null +++ b/libs/data-access/admin-api-fixtures/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/data-access/admin-api-fixtures/README.md b/libs/data-access/admin-api-fixtures/README.md new file mode 100644 index 00000000..ad901aa0 --- /dev/null +++ b/libs/data-access/admin-api-fixtures/README.md @@ -0,0 +1,3 @@ +# data-access-admin-api-fixtures + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/data-access/admin-api-fixtures/project.json b/libs/data-access/admin-api-fixtures/project.json new file mode 100644 index 00000000..cd995969 --- /dev/null +++ b/libs/data-access/admin-api-fixtures/project.json @@ -0,0 +1,9 @@ +{ + "name": "data-access-admin-api-fixtures", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/data-access/admin-api-fixtures/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project data-access-admin-api-fixtures --web", + "targets": {} +} diff --git a/libs/data-access/admin-api-fixtures/src/index.ts b/libs/data-access/admin-api-fixtures/src/index.ts new file mode 100644 index 00000000..32820fc8 --- /dev/null +++ b/libs/data-access/admin-api-fixtures/src/index.ts @@ -0,0 +1 @@ +export * from './lib/adminApiMockHandlers'; diff --git a/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts b/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts new file mode 100644 index 00000000..15008a73 --- /dev/null +++ b/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts @@ -0,0 +1,52 @@ +import { factory, manyOf, oneOf, primaryKey } from '@mswjs/data'; +import { faker } from '@faker-js/faker'; + +faker.seed(Date.now()); + +export const adminApiDb = factory({ + handler: { + name: primaryKey(() => `${faker.hacker.noun()}`), + ty: () => + faker.helpers.arrayElement(['Exclusive', 'Shared', 'Workflow'] as const), + input_description: () => + 'one of ["none", "value of content-type \'application/json\'"]', + output_description: () => "value of content-type 'application/json'", + }, + service: { + name: primaryKey(() => `${faker.hacker.noun()}Service`), + handlers: manyOf('handler'), + deployment: oneOf('deployment'), + ty: () => + faker.helpers.arrayElement([ + 'Service', + 'VirtualObject', + 'Workflow', + ] as const), + revision: () => faker.number.int(), + idempotency_retention: () => '1Day', + workflow_completion_retention: () => '1Day', + public: () => faker.datatype.boolean(), + }, + deployment: { + id: primaryKey(() => `dp_${faker.string.nanoid(27)}`), + services: manyOf('service'), + }, +}); + +const isE2E = process.env['SCENARIO'] === 'E2E'; + +if (!isE2E) { + const services = Array(3) + .fill(null) + .map(() => adminApiDb.service.create()); + Array(30) + .fill(null) + .map(() => + adminApiDb.deployment.create({ + services: services.slice( + 0, + Math.floor(Math.random() * services.length + 1) + ), + }) + ); +} diff --git a/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts b/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts new file mode 100644 index 00000000..9086f0ad --- /dev/null +++ b/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts @@ -0,0 +1,106 @@ +import * as adminApi from '@restate/data-access/admin-api/spec'; +import { http, HttpResponse } from 'msw'; +import { adminApiDb } from './adminApiDb'; +import { faker } from '@faker-js/faker'; + +type FormatParameterWithColon = + S extends `${infer A}{${infer P}}${infer B}` ? `${A}:${P}${B}` : S; +type GetPath = FormatParameterWithColon< + keyof Pick +>; + +const listDeploymentsHandler = http.get< + never, + never, + adminApi.operations['list_deployments']['responses']['200']['content']['application/json'], + GetPath<'/deployments'> +>('/deployments', async () => { + const deployments = adminApiDb.deployment.getAll(); + return HttpResponse.json({ + deployments: deployments.map((deployment) => ({ + id: deployment.id, + services: deployment.services, + uri: faker.internet.url(), + protocol_type: 'RequestResponse', + created_at: new Date().toISOString(), + http_version: 'HTTP/2.0', + min_protocol_version: 1, + max_protocol_version: 1, + })), + }); +}); + +const registerDeploymentHandler = http.post< + never, + adminApi.operations['create_deployment']['requestBody']['content']['application/json'], + adminApi.operations['create_deployment']['responses']['201']['content']['application/json'], + GetPath<'/deployments'> +>('/deployments', async ({ request }) => { + const requestBody = await request.json(); + const newDeployment = adminApiDb.deployment.create({}); + const services = Array(3) + .fill(null) + .map(() => adminApiDb.service.create({ deployment: newDeployment })); + + return HttpResponse.json({ + id: newDeployment.id, + services: services.map((service) => ({ + name: service.name, + deployment_id: service.deployment!.id, + public: service.public, + revision: service.revision, + ty: service.ty, + idempotency_retention: service.idempotency_retention, + workflow_completion_retention: service.idempotency_retention, + handlers: service.handlers.map((handler) => ({ + name: handler.name, + ty: handler.ty, + input_description: handler.input_description, + output_description: handler.output_description, + })), + })), + }); +}); + +const healthHandler = http.get< + never, + never, + adminApi.operations['health']['responses']['200']['content'], + GetPath<'/health'> +>('/health', async () => { + if (Math.random() < 0.5) { + return new HttpResponse(null, { status: 500 }); + } else { + return new HttpResponse(null, { status: 200 }); + } +}); + +const openApiHandler = http.get< + never, + never, + adminApi.operations['openapi_spec']['responses']['200']['content']['application/json'], + GetPath<'/openapi'> +>('/openapi', async () => { + return HttpResponse.json(adminApi.spec as any); +}); + +const versionHandler = http.get< + never, + never, + adminApi.operations['version']['responses']['200']['content']['application/json'], + GetPath<'/version'> +>('/version', async () => { + return HttpResponse.json({ + version: '1.1.1', + max_admin_api_version: 1, + min_admin_api_version: 1, + }); +}); + +export const adminApiMockHandlers = [ + listDeploymentsHandler, + healthHandler, + openApiHandler, + registerDeploymentHandler, + versionHandler, +]; diff --git a/libs/data-access/admin-api-fixtures/tsconfig.json b/libs/data-access/admin-api-fixtures/tsconfig.json new file mode 100644 index 00000000..9cc93474 --- /dev/null +++ b/libs/data-access/admin-api-fixtures/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "esModuleInterop": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/data-access/admin-api-fixtures/tsconfig.lib.json b/libs/data-access/admin-api-fixtures/tsconfig.lib.json new file mode 100644 index 00000000..8f9c818e --- /dev/null +++ b/libs/data-access/admin-api-fixtures/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/data-access/admin-api/.babelrc b/libs/data-access/admin-api/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/data-access/admin-api/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/data-access/admin-api/.eslintrc.json b/libs/data-access/admin-api/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/data-access/admin-api/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/data-access/admin-api/README.md b/libs/data-access/admin-api/README.md new file mode 100644 index 00000000..899bf04f --- /dev/null +++ b/libs/data-access/admin-api/README.md @@ -0,0 +1,7 @@ +# data-access-admin-api + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test data-access-admin-api` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/data-access/admin-api/project.json b/libs/data-access/admin-api/project.json new file mode 100644 index 00000000..334c7165 --- /dev/null +++ b/libs/data-access/admin-api/project.json @@ -0,0 +1,19 @@ +{ + "name": "admin-api", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/data-access/admin-api/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project data-access-admin-api --web", + "targets": { + "create": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "redocly lint ./libs/data-access/admin-api/src/lib/api/spec.json || true", + "openapi-typescript ./libs/data-access/admin-api/src/lib/api/spec.json -o ./libs/data-access/admin-api/src/lib/api/index.d.ts" + ] + } + } + } +} diff --git a/libs/data-access/admin-api/src/api.ts b/libs/data-access/admin-api/src/api.ts new file mode 100644 index 00000000..2cd7dc14 --- /dev/null +++ b/libs/data-access/admin-api/src/api.ts @@ -0,0 +1,4 @@ +import adminSpec from './lib/api/spec.json'; + +export type * from './lib/api'; +export const spec = adminSpec; diff --git a/libs/data-access/admin-api/src/index.ts b/libs/data-access/admin-api/src/index.ts new file mode 100644 index 00000000..3c9665d1 --- /dev/null +++ b/libs/data-access/admin-api/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib/api/client'; +export * from './lib/AdminBaseUrlProvider'; +export * from './lib/api/hooks'; +export type * from './lib/api/type'; diff --git a/libs/data-access/admin-api/src/lib/AdminBaseUrlProvider.tsx b/libs/data-access/admin-api/src/lib/AdminBaseUrlProvider.tsx new file mode 100644 index 00000000..f8d00fc8 --- /dev/null +++ b/libs/data-access/admin-api/src/lib/AdminBaseUrlProvider.tsx @@ -0,0 +1,23 @@ +import { createContext, PropsWithChildren, useContext } from 'react'; + +const AdminBaseURLContext = createContext<{ baseUrl: string }>({ baseUrl: '' }); + +export function AdminBaseURLProvider({ + children, + baseUrl = '', +}: PropsWithChildren<{ baseUrl?: string }>) { + return ( + + {children} + + ); +} + +export function useAdminBaseUrl() { + const { baseUrl } = useContext(AdminBaseURLContext); + return baseUrl; +} diff --git a/libs/data-access/admin-api/src/lib/api/client.ts b/libs/data-access/admin-api/src/lib/api/client.ts new file mode 100644 index 00000000..802f72d5 --- /dev/null +++ b/libs/data-access/admin-api/src/lib/api/client.ts @@ -0,0 +1,236 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { UnauthorizedError } from '@restate/util/errors'; +import type { paths } from './index'; // generated by openapi-typescript +import { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'; +import type { FetchResponse, Middleware } from 'openapi-fetch'; +import createClient from 'openapi-fetch'; + +class RestateError extends Error { + constructor(message: string, public restate_code?: string) { + super(message); + } +} + +const client = createClient({}); +const errorMiddleware: Middleware = { + async onResponse({ response }) { + if (!response.ok) { + if (response.status === 401) { + // TODO: change import + throw new UnauthorizedError(); + } + const body: + | string + | { + message: string; + restate_code?: string | null; + } = response.headers.get('content-type')?.includes('json') + ? await response.clone().json() + : await response.clone().text(); + + if (typeof body === 'object') { + throw new RestateError(body.message, body.restate_code ?? ''); + } + throw new Error(body); + } + return response; + }, +}; + +client.use(errorMiddleware); + +export type SupportedMethods = keyof { + [PossibleMethod in keyof paths[Path] as paths[Path][PossibleMethod] extends NonNullable<{ + parameters: { + query?: unknown; + header?: unknown; + path?: unknown; + cookie?: unknown; + }; + requestBody?: + | { + content: { + 'application/json': unknown; + }; + } + | never; + }> + ? PossibleMethod + : never]: paths[Path]; +}; + +export type OperationParameters< + Path extends keyof paths, + Method extends SupportedMethods +> = paths[Path][Method] extends { + parameters: { + query?: unknown; + header?: unknown; + path?: unknown; + cookie?: unknown; + }; +} + ? paths[Path][Method]['parameters'] + : never; + +export type OperationBody< + Path extends keyof paths, + Method extends SupportedMethods +> = paths[Path][Method] extends { + requestBody: { + content: { + 'application/json': unknown; + }; + }; +} + ? paths[Path][Method]['requestBody']['content']['application/json'] + : never; + +export type QueryOptions< + Path extends keyof paths, + Method extends SupportedMethods +> = UseQueryOptions< + FetchResponse['data'], + FetchResponse['error'] +>; + +type QueryFn< + Path extends keyof paths, + Method extends SupportedMethods +> = Extract['queryFn'], Function>; + +type QueryKey< + Path extends keyof paths, + Method extends SupportedMethods +> = QueryOptions['queryKey']; + +export type MutationOptions< + Path extends keyof paths, + Method extends SupportedMethods, + Parameters extends OperationParameters, + Body extends OperationBody +> = UseMutationOptions< + FetchResponse['data'], + FetchResponse['error'], + { + parameters?: Parameters; + body?: Body; + } +>; + +type MutationFn< + Path extends keyof paths, + Method extends SupportedMethods, + Parameters extends OperationParameters, + Body extends OperationBody +> = Extract< + MutationOptions['mutationFn'], + Function +>; + +type MutationKey< + Path extends keyof paths, + Method extends SupportedMethods, + Parameters extends OperationParameters, + Body extends OperationBody +> = MutationOptions['mutationKey']; + +export function adminApi< + Path extends keyof paths, + Method extends SupportedMethods, + Parameters extends OperationParameters, + Body extends OperationBody +>( + type: 'query', + path: Path, + method: Method, + init: { + baseUrl: string; + parameters?: Parameters; + body?: Body; + } +): { + queryFn: QueryFn; + queryKey: QueryKey; +}; +export function adminApi< + Path extends keyof paths, + Method extends SupportedMethods, + Parameters extends OperationParameters, + Body extends OperationBody +>( + type: 'mutate', + path: Path, + method: Method, + init: { + baseUrl: string; + } +): { + mutationFn: MutationFn; + mutationKey: MutationKey; +}; +export function adminApi< + Path extends keyof paths, + Method extends SupportedMethods, + Parameters extends OperationParameters, + Body extends OperationBody +>( + type: 'query' | 'mutate', + path: Path, + method: Method, + init: { + baseUrl: string; + parameters?: Parameters; + body?: Body; + } +): + | { + queryFn: QueryFn; + queryKey: QueryKey; + } + | { + mutationFn: MutationFn; + mutationKey: MutationKey; + } { + const key = [path, { ...init, method }]; + + if (type === 'query') { + return { + queryKey: key, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const { data } = await (client as any)[String(method).toUpperCase()]( + path, + { + baseUrl: init.baseUrl, + signal, + headers: { + Accept: 'json', + }, + body: init.body, + params: init.parameters, + ...(path === '/health' && { parseAs: 'stream' }), + } + ); + return data; + }, + }; + } else { + return { + mutationKey: key, + mutationFn: async (variables: { + parameters?: Parameters; + body?: Body; + }) => { + const { data } = await (client as any)[String(method).toUpperCase()]( + path, + { + baseUrl: init.baseUrl, + body: variables.body, + params: variables.parameters, + } + ); + return data; + }, + }; + } +} diff --git a/libs/data-access/admin-api/src/lib/api/hooks.ts b/libs/data-access/admin-api/src/lib/api/hooks.ts new file mode 100644 index 00000000..03eb7673 --- /dev/null +++ b/libs/data-access/admin-api/src/lib/api/hooks.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import type { paths } from './index'; // generated by openapi-typescript +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useAdminBaseUrl } from '../AdminBaseUrlProvider'; +import { + adminApi, + MutationOptions, + OperationBody, + OperationParameters, + QueryOptions, + SupportedMethods, +} from './client'; + +type HookQueryOptions< + Path extends keyof paths, + Method extends SupportedMethods +> = Omit, 'queryFn' | 'queryKey'>; + +type HookMutationOptions< + Path extends keyof paths, + Method extends SupportedMethods, + Parameters extends OperationParameters = OperationParameters< + Path, + Method + >, + Body extends OperationBody = OperationBody +> = Omit< + MutationOptions, + 'mutationFn' | 'mutationKey' +>; + +export function useListDeployments( + options?: HookQueryOptions<'/deployments', 'get'> +) { + const baseUrl = useAdminBaseUrl(); + + return useQuery({ + ...adminApi('query', '/deployments', 'get', { baseUrl }), + ...options, + }); +} + +export function useHealth(options?: HookQueryOptions<'/health', 'get'>) { + const baseUrl = useAdminBaseUrl(); + + return useQuery({ + ...adminApi('query', '/health', 'get', { baseUrl }), + ...options, + }); +} + +export function useOpenApi(options?: HookQueryOptions<'/openapi', 'get'>) { + const baseUrl = useAdminBaseUrl(); + + return useQuery({ + ...adminApi('query', '/openapi', 'get', { baseUrl }), + ...options, + }); +} + +export function useVersion(options?: HookQueryOptions<'/version', 'get'>) { + const baseUrl = useAdminBaseUrl(); + + return useQuery({ + ...adminApi('query', '/version', 'get', { baseUrl }), + ...options, + }); +} + +export function useRegisterDeployment( + options?: HookMutationOptions<'/deployments', 'post'> +) { + const baseUrl = useAdminBaseUrl(); + + return useMutation({ + ...adminApi('mutate', '/deployments', 'post', { baseUrl }), + ...options, + }); +} diff --git a/libs/data-access/admin-api/src/lib/api/index.d.ts b/libs/data-access/admin-api/src/lib/api/index.d.ts new file mode 100644 index 00000000..d76097d3 --- /dev/null +++ b/libs/data-access/admin-api/src/lib/api/index.d.ts @@ -0,0 +1,1643 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/services/{service}/state': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Modify a service state + * @description Modify service state + */ + post: operations['modify_service_state']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/services/{service}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get service + * @description Get a registered service. + */ + get: operations['get_service']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Modify a service + * @description Modify a registered service. + */ + patch: operations['modify_service']; + trace?: never; + }; + '/subscriptions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List subscriptions + * @description List all subscriptions. + */ + get: operations['list_subscriptions']; + put?: never; + /** + * Create subscription + * @description Create subscription. + */ + post: operations['create_subscription']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/invocations/{invocation_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete an invocation + * @description Delete the given invocation. By default, an invocation is terminated by gracefully cancelling it. This ensures virtual object state consistency. Alternatively, an invocation can be killed which does not guarantee consistency for virtual object instance state, in-flight invocations to other services, etc. A stored completed invocation can also be purged + */ + delete: operations['delete_invocation']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/subscriptions/{subscription}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get subscription + * @description Get subscription + */ + get: operations['get_subscription']; + put?: never; + post?: never; + /** + * Delete subscription + * @description Delete subscription. + */ + delete: operations['delete_subscription']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/version': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Admin version information + * @description Obtain admin version information. + */ + get: operations['version']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/services/{service}/handlers': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List service handlers + * @description List all the handlers of the given service. + */ + get: operations['list_service_handlers']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/services': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List services + * @description List all registered services. + */ + get: operations['list_services']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/deployments': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List deployments + * @description List all registered deployments. + */ + get: operations['list_deployments']; + put?: never; + /** + * Create deployment + * @description Create deployment. Restate will invoke the endpoint to gather additional information required for registration, such as the services exposed by the deployment. If the deployment is already registered, this method will fail unless `force` is set to `true`. + */ + post: operations['create_deployment']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/deployments/{deployment}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get deployment + * @description Get deployment metadata + */ + get: operations['get_deployment']; + put?: never; + post?: never; + /** + * Delete deployment + * @description Delete deployment. Currently it's supported to remove a deployment only using the force flag + */ + delete: operations['delete_deployment']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/health': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health check + * @description Check REST API Health. + */ + get: operations['health']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/services/{service}/handlers/{handler}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get service handler + * @description Get the handler of a service + */ + get: operations['get_service_handler']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/openapi': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** OpenAPI specification */ + get: operations['openapi_spec']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ModifyServiceStateRequest: { + /** + * Version + * @description If set, the latest version of the state is compared with this value and the operation will fail when the versions differ. + */ + version?: string | null; + /** + * Service key + * @description To what virtual object key to apply this change + */ + object_key: string; + /** + * New State + * @description The new state to replace the previous state with + */ + new_state: { + [key: string]: number[]; + }; + }; + /** + * Error description response + * @description Error details of the response + */ + ErrorDescriptionResponse: { + message: string; + /** + * Restate code + * @description Restate error code describing this error + */ + restate_code?: string | null; + }; + ServiceMetadata: { + /** + * Name + * @description Fully qualified name of the service + */ + name: string; + handlers: components['schemas']['HandlerMetadata'][]; + ty: components['schemas']['ServiceType']; + /** + * Deployment Id + * @description Deployment exposing the latest revision of the service. + */ + deployment_id: string; + /** + * Revision + * Format: uint32 + * @description Latest revision of the service. + */ + revision: number; + /** + * Public + * @description If true, the service can be invoked through the ingress. If false, the service can be invoked only from another Restate service. + */ + public: boolean; + /** + * Idempotency retention + * @description The retention duration of idempotent requests for this service. + */ + idempotency_retention: string; + /** + * Workflow completion retention + * @description The retention duration of workflows. Only available on workflow services. + */ + workflow_completion_retention?: string | null; + }; + HandlerMetadata: { + name: string; + ty: components['schemas']['HandlerMetadataType']; + input_description: string; + output_description: string; + }; + /** @enum {string} */ + HandlerMetadataType: 'Exclusive' | 'Shared' | 'Workflow'; + /** @enum {string} */ + ServiceType: 'Service' | 'VirtualObject' | 'Workflow'; + ListSubscriptionsResponse: { + subscriptions: components['schemas']['SubscriptionResponse'][]; + }; + SubscriptionResponse: { + id: components['schemas']['String']; + source: string; + sink: string; + options: { + [key: string]: string; + }; + }; + String: string; + ModifyServiceRequest: { + /** + * Public + * @description If true, the service can be invoked through the ingress. If false, the service can be invoked only from another Restate service. + */ + public?: boolean | null; + /** + * Idempotency retention + * @description Modify the retention of idempotent requests for this service. + * + * Can be configured using the [`humantime`](https://docs.rs/humantime/latest/humantime/fn.parse_duration.html) format or the ISO8601. + */ + idempotency_retention?: string | null; + /** + * Workflow completion retention + * @description Modify the retention of the workflow completion. This can be modified only for workflow services! + * + * Can be configured using the [`humantime`](https://docs.rs/humantime/latest/humantime/fn.parse_duration.html) format or the ISO8601. + */ + workflow_completion_retention?: string | null; + }; + /** @enum {string} */ + DeletionMode: 'Cancel' | 'Kill' | 'Purge'; + VersionInformation: { + /** + * Admin server version + * @description Version of the admin server + */ + version: string; + /** + * Min admin API version + * Format: uint16 + * @description Minimum supported admin API version by the admin server + */ + min_admin_api_version: number; + /** + * Max admin API version + * Format: uint16 + * @description Maximum supported admin API version by the admin server + */ + max_admin_api_version: number; + }; + ListServiceHandlersResponse: { + handlers: components['schemas']['HandlerMetadata'][]; + }; + ListServicesResponse: { + services: components['schemas']['ServiceMetadata'][]; + }; + CreateSubscriptionRequest: { + /** + * Source + * @description Source uri. Accepted forms: + * + * * `kafka:///`, e.g. `kafka://my-cluster/my-topic` + */ + source: string; + /** + * Sink + * @description Sink uri. Accepted forms: + * + * * `service:///`, e.g. `service://Counter/count` + */ + sink: string; + /** + * Options + * @description Additional options to apply to the subscription. + */ + options?: { + [key: string]: string; + } | null; + }; + RegisterDeploymentRequest: + | { + /** + * Uri + * @description Uri to use to discover/invoke the http deployment. + */ + uri: string; + /** + * Additional headers + * @description Additional headers added to the discover/invoke requests to the deployment. + */ + additional_headers?: { + [key: string]: string; + } | null; + /** + * Use http1.1 + * @description If `true`, discovery will be attempted using a client that defaults to HTTP1.1 instead of a prior-knowledge HTTP2 client. HTTP2 may still be used for TLS servers that advertise HTTP2 support via ALPN. HTTP1.1 deployments will only work in request-response mode. + * @default false + */ + use_http_11: boolean; + /** + * Force + * @description If `true`, it will override, if existing, any deployment using the same `uri`. Beware that this can lead in-flight invocations to an unrecoverable error state. + * + * By default, this is `true` but it might change in future to `false`. + * + * See the [versioning documentation](https://docs.restate.dev/operate/versioning) for more information. + * @default true + */ + force: boolean; + /** + * Dry-run mode + * @description If `true`, discovery will run but the deployment will not be registered. This is useful to see the impact of a new deployment before registering it. + * @default false + */ + dry_run: boolean; + } + | { + /** + * ARN + * @description ARN to use to discover/invoke the lambda deployment. + */ + arn: string; + /** + * Assume role ARN + * @description Optional ARN of a role to assume when invoking the addressed Lambda, to support role chaining + */ + assume_role_arn?: string | null; + /** + * Additional headers + * @description Additional headers added to the discover/invoke requests to the deployment. + */ + additional_headers?: { + [key: string]: string; + } | null; + /** + * Force + * @description If `true`, it will override, if existing, any deployment using the same `uri`. Beware that this can lead in-flight invocations to an unrecoverable error state. + * + * By default, this is `true` but it might change in future to `false`. + * + * See the [versioning documentation](https://docs.restate.dev/operate/versioning) for more information. + * @default true + */ + force: boolean; + /** + * Dry-run mode + * @description If `true`, discovery will run but the deployment will not be registered. This is useful to see the impact of a new deployment before registering it. + * @default false + */ + dry_run: boolean; + }; + RegisterDeploymentResponse: { + id: components['schemas']['String']; + services: components['schemas']['ServiceMetadata'][]; + }; + DetailedDeploymentResponse: { + id: components['schemas']['String']; + /** + * Services + * @description List of services exposed by this deployment. + */ + services: components['schemas']['ServiceMetadata'][]; + } & ( + | { + uri: string; + protocol_type: components['schemas']['ProtocolType']; + http_version: string; + additional_headers?: { + [key: string]: string; + }; + created_at: string; + /** Format: int32 */ + min_protocol_version: number; + /** Format: int32 */ + max_protocol_version: number; + } + | { + arn: components['schemas']['LambdaARN']; + assume_role_arn?: string | null; + additional_headers?: { + [key: string]: string; + }; + created_at: string; + /** Format: int32 */ + min_protocol_version: number; + /** Format: int32 */ + max_protocol_version: number; + } + ); + /** @enum {string} */ + ProtocolType: 'RequestResponse' | 'BidiStream'; + /** Format: arn */ + LambdaARN: string; + ListDeploymentsResponse: { + deployments: components['schemas']['DeploymentResponse'][]; + }; + DeploymentResponse: { + id: components['schemas']['String']; + /** + * Services + * @description List of services exposed by this deployment. + */ + services: components['schemas']['ServiceNameRevPair'][]; + } & ( + | { + uri: string; + protocol_type: components['schemas']['ProtocolType']; + http_version: string; + additional_headers?: { + [key: string]: string; + }; + created_at: string; + /** Format: int32 */ + min_protocol_version: number; + /** Format: int32 */ + max_protocol_version: number; + } + | { + arn: components['schemas']['LambdaARN']; + assume_role_arn?: string | null; + additional_headers?: { + [key: string]: string; + }; + created_at: string; + /** Format: int32 */ + min_protocol_version: number; + /** Format: int32 */ + max_protocol_version: number; + } + ); + ServiceNameRevPair: { + name: string; + /** Format: uint32 */ + revision: number; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + modify_service_state: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Fully qualified service name. */ + service: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ModifyServiceStateRequest']; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + get_service: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Fully qualified service name. */ + service: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ServiceMetadata']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + modify_service: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Fully qualified service name. */ + service: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ModifyServiceRequest']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ServiceMetadata']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + list_subscriptions: { + parameters: { + query?: { + /** @description Filter by the exact specified sink. */ + sink?: string; + /** @description Filter by the exact specified source. */ + source?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListSubscriptionsResponse']; + }; + }; + }; + }; + create_subscription: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CreateSubscriptionRequest']; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SubscriptionResponse']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + delete_invocation: { + parameters: { + query?: { + /** @description If cancel, it will gracefully terminate the invocation. If kill, it will terminate the invocation with a hard stop. If purge, it will only cleanup the response for completed invocations, and leave unaffected an in-flight invocation. */ + mode?: components['schemas']['DeletionMode']; + }; + header?: never; + path: { + /** @description Invocation identifier. */ + invocation_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + get_subscription: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Subscription identifier */ + subscription: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SubscriptionResponse']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + delete_subscription: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Subscription identifier */ + subscription: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + version: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['VersionInformation']; + }; + }; + }; + }; + list_service_handlers: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Fully qualified service name. */ + service: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListServiceHandlersResponse']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + list_services: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListServicesResponse']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + list_deployments: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListDeploymentsResponse']; + }; + }; + }; + }; + create_deployment: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['RegisterDeploymentRequest']; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['RegisterDeploymentResponse']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + get_deployment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Deployment identifier */ + deployment: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DetailedDeploymentResponse']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + delete_deployment: { + parameters: { + query?: { + /** @description If true, the deployment will be forcefully deleted. This might break in-flight invocations, use with caution. */ + force?: boolean; + }; + header?: never; + path: { + /** @description Deployment identifier */ + deployment: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + /** @description Not implemented. Only using the force flag is supported at the moment. */ + 501: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get_service_handler: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Fully qualified service name. */ + service: string; + /** @description Handler name. */ + handler: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HandlerMetadata']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + openapi_spec: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + [key: string]: string; + }; + }; + }; + }; + }; +} diff --git a/libs/data-access/admin-api/src/lib/api/spec.json b/libs/data-access/admin-api/src/lib/api/spec.json new file mode 100644 index 00000000..12246e93 --- /dev/null +++ b/libs/data-access/admin-api/src/lib/api/spec.json @@ -0,0 +1,1898 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Admin API", + "version": "1.1.0" + }, + "paths": { + "/services/{service}/state": { + "post": { + "tags": ["service"], + "summary": "Modify a service state", + "description": "Modify service state", + "operationId": "modify_service_state", + "parameters": [ + { + "name": "service", + "in": "path", + "description": "Fully qualified service name.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModifyServiceStateRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/services/{service}": { + "get": { + "tags": ["service"], + "summary": "Get service", + "description": "Get a registered service.", + "operationId": "get_service", + "parameters": [ + { + "name": "service", + "in": "path", + "description": "Fully qualified service name.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceMetadata" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + }, + "patch": { + "tags": ["service"], + "summary": "Modify a service", + "description": "Modify a registered service.", + "operationId": "modify_service", + "parameters": [ + { + "name": "service", + "in": "path", + "description": "Fully qualified service name.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModifyServiceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceMetadata" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/subscriptions": { + "get": { + "tags": ["subscription"], + "summary": "List subscriptions", + "description": "List all subscriptions.", + "operationId": "list_subscriptions", + "parameters": [ + { + "name": "sink", + "in": "query", + "description": "Filter by the exact specified sink.", + "style": "simple", + "schema": { + "type": "string" + } + }, + { + "name": "source", + "in": "query", + "description": "Filter by the exact specified source.", + "style": "simple", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSubscriptionsResponse" + } + } + } + } + } + }, + "post": { + "tags": ["subscription"], + "summary": "Create subscription", + "description": "Create subscription.", + "operationId": "create_subscription", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSubscriptionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/invocations/{invocation_id}": { + "delete": { + "tags": ["invocation"], + "summary": "Delete an invocation", + "description": "Delete the given invocation. By default, an invocation is terminated by gracefully cancelling it. This ensures virtual object state consistency. Alternatively, an invocation can be killed which does not guarantee consistency for virtual object instance state, in-flight invocations to other services, etc. A stored completed invocation can also be purged", + "operationId": "delete_invocation", + "parameters": [ + { + "name": "invocation_id", + "in": "path", + "description": "Invocation identifier.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "description": "If cancel, it will gracefully terminate the invocation. If kill, it will terminate the invocation with a hard stop. If purge, it will only cleanup the response for completed invocations, and leave unaffected an in-flight invocation.", + "style": "simple", + "schema": { + "$ref": "#/components/schemas/DeletionMode" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/subscriptions/{subscription}": { + "get": { + "tags": ["subscription"], + "summary": "Get subscription", + "description": "Get subscription", + "operationId": "get_subscription", + "parameters": [ + { + "name": "subscription", + "in": "path", + "description": "Subscription identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + }, + "delete": { + "tags": ["subscription"], + "summary": "Delete subscription", + "description": "Delete subscription.", + "operationId": "delete_subscription", + "parameters": [ + { + "name": "subscription", + "in": "path", + "description": "Subscription identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/version": { + "get": { + "tags": ["version"], + "summary": "Admin version information", + "description": "Obtain admin version information.", + "operationId": "version", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionInformation" + } + } + } + } + } + } + }, + "/services/{service}/handlers": { + "get": { + "tags": ["service_handler"], + "summary": "List service handlers", + "description": "List all the handlers of the given service.", + "operationId": "list_service_handlers", + "parameters": [ + { + "name": "service", + "in": "path", + "description": "Fully qualified service name.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListServiceHandlersResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/services": { + "get": { + "tags": ["service"], + "summary": "List services", + "description": "List all registered services.", + "operationId": "list_services", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListServicesResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/deployments": { + "get": { + "tags": ["deployment"], + "summary": "List deployments", + "description": "List all registered deployments.", + "operationId": "list_deployments", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListDeploymentsResponse" + } + } + } + } + } + }, + "post": { + "tags": ["deployment"], + "summary": "Create deployment", + "description": "Create deployment. Restate will invoke the endpoint to gather additional information required for registration, such as the services exposed by the deployment. If the deployment is already registered, this method will fail unless `force` is set to `true`.", + "operationId": "create_deployment", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterDeploymentRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterDeploymentResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/deployments/{deployment}": { + "get": { + "tags": ["deployment"], + "summary": "Get deployment", + "description": "Get deployment metadata", + "operationId": "get_deployment", + "parameters": [ + { + "name": "deployment", + "in": "path", + "description": "Deployment identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DetailedDeploymentResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + }, + "delete": { + "tags": ["deployment"], + "summary": "Delete deployment", + "description": "Delete deployment. Currently it's supported to remove a deployment only using the force flag", + "operationId": "delete_deployment", + "parameters": [ + { + "name": "deployment", + "in": "path", + "description": "Deployment identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "description": "If true, the deployment will be forcefully deleted. This might break in-flight invocations, use with caution.", + "style": "simple", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "501": { + "description": "Not implemented. Only using the force flag is supported at the moment." + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": ["health"], + "summary": "Health check", + "description": "Check REST API Health.", + "operationId": "health", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/services/{service}/handlers/{handler}": { + "get": { + "tags": ["service_handler"], + "summary": "Get service handler", + "description": "Get the handler of a service", + "operationId": "get_service_handler", + "parameters": [ + { + "name": "service", + "in": "path", + "description": "Fully qualified service name.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "handler", + "in": "path", + "description": "Handler name.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HandlerMetadata" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/openapi": { + "get": { + "tags": ["openapi"], + "summary": "OpenAPI specification", + "externalDocs": { + "url": "https://swagger.io/specification/" + }, + "operationId": "openapi_spec", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ModifyServiceStateRequest": { + "type": "object", + "required": ["new_state", "object_key"], + "properties": { + "version": { + "title": "Version", + "description": "If set, the latest version of the state is compared with this value and the operation will fail when the versions differ.", + "type": "string", + "nullable": true + }, + "object_key": { + "title": "Service key", + "description": "To what virtual object key to apply this change", + "type": "string" + }, + "new_state": { + "title": "New State", + "description": "The new state to replace the previous state with", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + } + } + } + }, + "ErrorDescriptionResponse": { + "title": "Error description response", + "description": "Error details of the response", + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + }, + "restate_code": { + "title": "Restate code", + "description": "Restate error code describing this error", + "type": "string", + "nullable": true + } + } + }, + "ServiceMetadata": { + "type": "object", + "required": [ + "deployment_id", + "handlers", + "idempotency_retention", + "name", + "public", + "revision", + "ty" + ], + "properties": { + "name": { + "title": "Name", + "description": "Fully qualified name of the service", + "type": "string" + }, + "handlers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HandlerMetadata" + } + }, + "ty": { + "$ref": "#/components/schemas/ServiceType" + }, + "deployment_id": { + "title": "Deployment Id", + "description": "Deployment exposing the latest revision of the service.", + "type": "string" + }, + "revision": { + "title": "Revision", + "description": "Latest revision of the service.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "public": { + "title": "Public", + "description": "If true, the service can be invoked through the ingress. If false, the service can be invoked only from another Restate service.", + "type": "boolean" + }, + "idempotency_retention": { + "title": "Idempotency retention", + "description": "The retention duration of idempotent requests for this service.", + "type": "string" + }, + "workflow_completion_retention": { + "title": "Workflow completion retention", + "description": "The retention duration of workflows. Only available on workflow services.", + "type": "string", + "nullable": true + } + } + }, + "HandlerMetadata": { + "type": "object", + "required": ["input_description", "name", "output_description", "ty"], + "properties": { + "name": { + "type": "string" + }, + "ty": { + "$ref": "#/components/schemas/HandlerMetadataType" + }, + "input_description": { + "type": "string" + }, + "output_description": { + "type": "string" + } + } + }, + "HandlerMetadataType": { + "type": "string", + "enum": ["Exclusive", "Shared", "Workflow"] + }, + "ServiceType": { + "type": "string", + "enum": ["Service", "VirtualObject", "Workflow"] + }, + "ListSubscriptionsResponse": { + "type": "object", + "required": ["subscriptions"], + "properties": { + "subscriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubscriptionResponse" + } + } + } + }, + "SubscriptionResponse": { + "type": "object", + "required": ["id", "options", "sink", "source"], + "properties": { + "id": { + "$ref": "#/components/schemas/String" + }, + "source": { + "type": "string" + }, + "sink": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "String": { + "type": "string" + }, + "ModifyServiceRequest": { + "type": "object", + "properties": { + "public": { + "title": "Public", + "description": "If true, the service can be invoked through the ingress. If false, the service can be invoked only from another Restate service.", + "type": "boolean", + "nullable": true + }, + "idempotency_retention": { + "title": "Idempotency retention", + "description": "Modify the retention of idempotent requests for this service.\n\nCan be configured using the [`humantime`](https://docs.rs/humantime/latest/humantime/fn.parse_duration.html) format or the ISO8601.", + "type": "string", + "nullable": true + }, + "workflow_completion_retention": { + "title": "Workflow completion retention", + "description": "Modify the retention of the workflow completion. This can be modified only for workflow services!\n\nCan be configured using the [`humantime`](https://docs.rs/humantime/latest/humantime/fn.parse_duration.html) format or the ISO8601.", + "type": "string", + "nullable": true + } + } + }, + "DeletionMode": { + "type": "string", + "enum": ["Cancel", "Kill", "Purge"] + }, + "VersionInformation": { + "type": "object", + "required": [ + "max_admin_api_version", + "min_admin_api_version", + "version" + ], + "properties": { + "version": { + "title": "Admin server version", + "description": "Version of the admin server", + "type": "string" + }, + "min_admin_api_version": { + "title": "Min admin API version", + "description": "Minimum supported admin API version by the admin server", + "type": "integer", + "format": "uint16", + "minimum": 0.0 + }, + "max_admin_api_version": { + "title": "Max admin API version", + "description": "Maximum supported admin API version by the admin server", + "type": "integer", + "format": "uint16", + "minimum": 0.0 + } + } + }, + "ListServiceHandlersResponse": { + "type": "object", + "required": ["handlers"], + "properties": { + "handlers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HandlerMetadata" + } + } + } + }, + "ListServicesResponse": { + "type": "object", + "required": ["services"], + "properties": { + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceMetadata" + } + } + } + }, + "CreateSubscriptionRequest": { + "type": "object", + "required": ["sink", "source"], + "properties": { + "source": { + "title": "Source", + "description": "Source uri. Accepted forms:\n\n* `kafka:///`, e.g. `kafka://my-cluster/my-topic`", + "type": "string" + }, + "sink": { + "title": "Sink", + "description": "Sink uri. Accepted forms:\n\n* `service:///`, e.g. `service://Counter/count`", + "type": "string" + }, + "options": { + "title": "Options", + "description": "Additional options to apply to the subscription.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + } + } + }, + "RegisterDeploymentRequest": { + "anyOf": [ + { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { + "title": "Uri", + "description": "Uri to use to discover/invoke the http deployment.", + "type": "string" + }, + "additional_headers": { + "title": "Additional headers", + "description": "Additional headers added to the discover/invoke requests to the deployment.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "use_http_11": { + "title": "Use http1.1", + "description": "If `true`, discovery will be attempted using a client that defaults to HTTP1.1 instead of a prior-knowledge HTTP2 client. HTTP2 may still be used for TLS servers that advertise HTTP2 support via ALPN. HTTP1.1 deployments will only work in request-response mode.", + "default": false, + "type": "boolean" + }, + "force": { + "title": "Force", + "description": "If `true`, it will override, if existing, any deployment using the same `uri`. Beware that this can lead in-flight invocations to an unrecoverable error state.\n\nBy default, this is `true` but it might change in future to `false`.\n\nSee the [versioning documentation](https://docs.restate.dev/operate/versioning) for more information.", + "default": true, + "type": "boolean" + }, + "dry_run": { + "title": "Dry-run mode", + "description": "If `true`, discovery will run but the deployment will not be registered. This is useful to see the impact of a new deployment before registering it.", + "default": false, + "type": "boolean" + } + } + }, + { + "type": "object", + "required": ["arn"], + "properties": { + "arn": { + "title": "ARN", + "description": "ARN to use to discover/invoke the lambda deployment.", + "type": "string" + }, + "assume_role_arn": { + "title": "Assume role ARN", + "description": "Optional ARN of a role to assume when invoking the addressed Lambda, to support role chaining", + "type": "string", + "nullable": true + }, + "additional_headers": { + "title": "Additional headers", + "description": "Additional headers added to the discover/invoke requests to the deployment.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "force": { + "title": "Force", + "description": "If `true`, it will override, if existing, any deployment using the same `uri`. Beware that this can lead in-flight invocations to an unrecoverable error state.\n\nBy default, this is `true` but it might change in future to `false`.\n\nSee the [versioning documentation](https://docs.restate.dev/operate/versioning) for more information.", + "default": true, + "type": "boolean" + }, + "dry_run": { + "title": "Dry-run mode", + "description": "If `true`, discovery will run but the deployment will not be registered. This is useful to see the impact of a new deployment before registering it.", + "default": false, + "type": "boolean" + } + } + } + ] + }, + "RegisterDeploymentResponse": { + "type": "object", + "required": ["id", "services"], + "properties": { + "id": { + "$ref": "#/components/schemas/String" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceMetadata" + } + } + } + }, + "DetailedDeploymentResponse": { + "type": "object", + "anyOf": [ + { + "type": "object", + "required": [ + "created_at", + "http_version", + "max_protocol_version", + "min_protocol_version", + "protocol_type", + "uri" + ], + "properties": { + "uri": { + "type": "string" + }, + "protocol_type": { + "$ref": "#/components/schemas/ProtocolType" + }, + "http_version": { + "type": "string" + }, + "additional_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "min_protocol_version": { + "type": "integer", + "format": "int32" + }, + "max_protocol_version": { + "type": "integer", + "format": "int32" + } + } + }, + { + "type": "object", + "required": [ + "arn", + "created_at", + "max_protocol_version", + "min_protocol_version" + ], + "properties": { + "arn": { + "$ref": "#/components/schemas/LambdaARN" + }, + "assume_role_arn": { + "type": "string", + "nullable": true + }, + "additional_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "min_protocol_version": { + "type": "integer", + "format": "int32" + }, + "max_protocol_version": { + "type": "integer", + "format": "int32" + } + } + } + ], + "required": ["id", "services"], + "properties": { + "id": { + "$ref": "#/components/schemas/String" + }, + "services": { + "title": "Services", + "description": "List of services exposed by this deployment.", + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceMetadata" + } + } + } + }, + "ProtocolType": { + "type": "string", + "enum": ["RequestResponse", "BidiStream"] + }, + "LambdaARN": { + "type": "string", + "format": "arn" + }, + "ListDeploymentsResponse": { + "type": "object", + "required": ["deployments"], + "properties": { + "deployments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeploymentResponse" + } + } + } + }, + "DeploymentResponse": { + "type": "object", + "anyOf": [ + { + "type": "object", + "required": [ + "created_at", + "http_version", + "max_protocol_version", + "min_protocol_version", + "protocol_type", + "uri" + ], + "properties": { + "uri": { + "type": "string" + }, + "protocol_type": { + "$ref": "#/components/schemas/ProtocolType" + }, + "http_version": { + "type": "string" + }, + "additional_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "min_protocol_version": { + "type": "integer", + "format": "int32" + }, + "max_protocol_version": { + "type": "integer", + "format": "int32" + } + } + }, + { + "type": "object", + "required": [ + "arn", + "created_at", + "max_protocol_version", + "min_protocol_version" + ], + "properties": { + "arn": { + "$ref": "#/components/schemas/LambdaARN" + }, + "assume_role_arn": { + "type": "string", + "nullable": true + }, + "additional_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "min_protocol_version": { + "type": "integer", + "format": "int32" + }, + "max_protocol_version": { + "type": "integer", + "format": "int32" + } + } + } + ], + "required": ["id", "services"], + "properties": { + "id": { + "$ref": "#/components/schemas/String" + }, + "services": { + "title": "Services", + "description": "List of services exposed by this deployment.", + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceNameRevPair" + } + } + } + }, + "ServiceNameRevPair": { + "type": "object", + "required": ["name", "revision"], + "properties": { + "name": { + "type": "string" + }, + "revision": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + } + } + } +} diff --git a/libs/data-access/admin-api/src/lib/api/type.ts b/libs/data-access/admin-api/src/lib/api/type.ts new file mode 100644 index 00000000..65d8f518 --- /dev/null +++ b/libs/data-access/admin-api/src/lib/api/type.ts @@ -0,0 +1,6 @@ +import type { components } from './index'; // generated by openapi-typescript + +export type Deployment = + components['schemas']['ListDeploymentsResponse']['deployments'][number]; +export type DetailedDeployment = + components['schemas']['DetailedDeploymentResponse']; diff --git a/libs/data-access/admin-api/tsconfig.json b/libs/data-access/admin-api/tsconfig.json new file mode 100644 index 00000000..50b36f3d --- /dev/null +++ b/libs/data-access/admin-api/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "resolveJsonModule": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/data-access/admin-api/tsconfig.lib.json b/libs/data-access/admin-api/tsconfig.lib.json new file mode 100644 index 00000000..eb8cfbfc --- /dev/null +++ b/libs/data-access/admin-api/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + "../../../@types/global-env.d.ts", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/data-access/admin-api/tsconfig.spec.json b/libs/data-access/admin-api/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/data-access/admin-api/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/data-access/admin-api/vite.config.ts b/libs/data-access/admin-api/vite.config.ts new file mode 100644 index 00000000..fec0b13b --- /dev/null +++ b/libs/data-access/admin-api/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/data-access/admin-api', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + watch: false, + globals: true, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/data-access/admin-api', + provider: 'v8', + }, + }, +}); diff --git a/libs/features/overview-route/.babelrc b/libs/features/overview-route/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/features/overview-route/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/features/overview-route/.eslintrc.json b/libs/features/overview-route/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/features/overview-route/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/features/overview-route/README.md b/libs/features/overview-route/README.md new file mode 100644 index 00000000..8ac97388 --- /dev/null +++ b/libs/features/overview-route/README.md @@ -0,0 +1,7 @@ +# features-overview-route + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test features-overview-route` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/features/overview-route/project.json b/libs/features/overview-route/project.json new file mode 100644 index 00000000..c20257ab --- /dev/null +++ b/libs/features/overview-route/project.json @@ -0,0 +1,9 @@ +{ + "name": "features-overview-route", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/features/overview-route/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project features-overview-route --web", + "targets": {} +} diff --git a/libs/features/overview-route/src/index.ts b/libs/features/overview-route/src/index.ts new file mode 100644 index 00000000..295eee53 --- /dev/null +++ b/libs/features/overview-route/src/index.ts @@ -0,0 +1 @@ +export * from './lib/overview.route'; diff --git a/libs/features/overview-route/src/lib/Deployment.tsx b/libs/features/overview-route/src/lib/Deployment.tsx new file mode 100644 index 00000000..fa69c890 --- /dev/null +++ b/libs/features/overview-route/src/lib/Deployment.tsx @@ -0,0 +1,61 @@ +import type { Deployment } from '@restate/data-access/admin-api'; +import { Icon, IconName } from '@restate/ui/icons'; +import { tv } from 'tailwind-variants'; +import { isHttpDeployment } from './types'; +import { Service } from './Service'; +import { Link } from '@restate/ui/link'; + +const styles = tv({ + base: 'w-full rounded-xl border bg-gray-200/50 shadow-[inset_0_1px_0px_0px_rgba(0,0,0,0.03)]', +}); + +function getDeploymentIdentifier(deployment: Deployment) { + if (isHttpDeployment(deployment)) { + return deployment.uri; + } else { + return deployment.arn; + } +} + +export function Deployment({ + deployment, + className, +}: { + deployment: Deployment; + className?: string; +}) { + return ( +
+ +
+
+
+ +
+
+
+ {getDeploymentIdentifier(deployment)} +
+
+ + {deployment.services.length > 0 && ( +
+
+ Services +
+ {deployment.services.map((service) => ( + + ))} +
+ )} +
+ ); +} diff --git a/libs/features/overview-route/src/lib/Details.tsx/Deployment.tsx b/libs/features/overview-route/src/lib/Details.tsx/Deployment.tsx new file mode 100644 index 00000000..03c5f9fa --- /dev/null +++ b/libs/features/overview-route/src/lib/Details.tsx/Deployment.tsx @@ -0,0 +1,29 @@ +import { Button } from '@restate/ui/button'; +import { + ComplementaryWithSearchParam, + ComplementaryClose, +} from '@restate/ui/layout'; + +export function DeploymentDetails() { + return ( + + + + + + + } + > + {DeploymentForm} + + ); +} + +function DeploymentForm({ paramValue }: { paramValue: string }) { + return
{paramValue}
; +} diff --git a/libs/features/overview-route/src/lib/Details.tsx/Service.tsx b/libs/features/overview-route/src/lib/Details.tsx/Service.tsx new file mode 100644 index 00000000..e4da914d --- /dev/null +++ b/libs/features/overview-route/src/lib/Details.tsx/Service.tsx @@ -0,0 +1,29 @@ +import { Button } from '@restate/ui/button'; +import { + ComplementaryWithSearchParam, + ComplementaryClose, +} from '@restate/ui/layout'; + +export function ServiceDetails() { + return ( + + + + + + + } + > + {ServiceForm} + + ); +} + +function ServiceForm({ paramValue }: { paramValue: string }) { + return
{paramValue}
; +} diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx new file mode 100644 index 00000000..3421e84f --- /dev/null +++ b/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx @@ -0,0 +1,97 @@ +import { Button } from '@restate/ui/button'; +import { + FormFieldGroup, + FormFieldLabel, + FormFieldInput, +} from '@restate/ui/form-field'; +import { IconName, Icon } from '@restate/ui/icons'; +import { useListData } from 'react-stately'; + +export function AdditionalHeaders() { + const list = useListData<{ key: string; value: string; index: number }>({ + initialItems: [{ key: '', value: '', index: 0 }], + getKey: (item) => item.index, + }); + + return ( + + + + Additional headers + + + Headers added to the discover/invoke requests to the deployment. + + + {list.items.map((item) => ( +
+ + list.update(item.index, { + ...item, + key, + }) + } + /> + : + + list.update(item.index, { + ...item, + value, + }) + } + /> + +
+ ))} + +
+ ); +} diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx new file mode 100644 index 00000000..3b307642 --- /dev/null +++ b/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx @@ -0,0 +1,20 @@ +import { FormFieldInput } from '@restate/ui/form-field'; + +export function AssumeARNRole() { + return ( + + Assume role ARN + + Optional ARN of a role to assume when invoking the addressed Lambda, + to support role chaining + + + } + /> + ); +} diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx new file mode 100644 index 00000000..b067f629 --- /dev/null +++ b/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx @@ -0,0 +1,82 @@ +import { Button, SubmitButton } from '@restate/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogTrigger, +} from '@restate/ui/dialog'; +import { Icon, IconName } from '@restate/ui/icons'; +import { PropsWithChildren } from 'react'; +import { ErrorBanner } from '@restate/ui/error'; +import { RegistrationForm } from './Form'; + +function RegisterDeploymentFooter({ + isDryRun, + setIsDryRun, + error, + isPending, + formId, +}: { + isDryRun: boolean; + formId: string; + isPending: boolean; + setIsDryRun: (value: boolean) => void; + error?: { + message: string; + restate_code?: string | null; + } | null; +}) { + return ( + +
+ {error && } +
+ {isDryRun ? ( + + + + ) : ( + + )} + + {isDryRun ? 'Next' : 'Confirm'} + +
+
+
+ ); +} + +export function TriggerRegisterDeploymentDialog({ + children = 'Register deployment', +}: PropsWithChildren>) { + return ( + + + + + + {RegisterDeploymentFooter} + + + ); +} diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx new file mode 100644 index 00000000..51ba79b9 --- /dev/null +++ b/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx @@ -0,0 +1,258 @@ +import { Form } from '@remix-run/react'; +import { + useListDeployments, + useRegisterDeployment, +} from '@restate/data-access/admin-api'; +import { useDialog } from '@restate/ui/dialog'; +import { FormFieldCheckbox, FormFieldInput } from '@restate/ui/form-field'; +import { Icon, IconName } from '@restate/ui/icons'; +import { + FormEvent, + PropsWithChildren, + ReactNode, + useEffect, + useId, + useState, +} from 'react'; +import { Radio } from 'react-aria-components'; +import { RadioGroup } from '@restate/ui/radio-group'; +import { RegisterDeploymentResults } from './Results'; +import { AdditionalHeaders } from '../RegisterDeployment/AdditionalHeaders'; +import { DeploymentType } from '../types'; +import { UseHTTP11 } from '../RegisterDeployment/UseHTTP11'; +import { AssumeARNRole } from '../RegisterDeployment/AssumeARNRole'; + +function CustomRadio({ + value, + children, + className, +}: PropsWithChildren<{ + value: string; + className?: string; +}>) { + return ( + `${className} + group relative flex cursor-default rounded-lg shadow-none outline-none bg-clip-padding border + ${ + isFocusVisible + ? 'ring-2 ring-blue-600 ring-offset-1 ring-offset-white/80' + : '' + } + ${ + isSelected + ? `${ + isPressed ? 'bg-gray-50' : 'bg-white' + } border shadow-sm text-gray-800 scale-105 z-10` + : 'border-transparent text-gray-500' + } + ${isPressed && !isSelected ? 'bg-gray-100' : ''} + ${!isSelected && !isPressed ? 'bg-white/50' : ''} + `} + > + {children} + + ); +} +// TODO: change type on paste +// fix autofocus +function RegistrationFormFields({ + children, + className = '', +}: PropsWithChildren<{ className?: string }>) { + const [type, setType] = useState('uri'); + const isURI = type === 'uri'; + const isLambda = type === 'arn'; + + return ( + <> +
+

+ Register deployment +

+

+ Point Restate to your deployed services so Restate can discover and + register your services and handlers +

+
+
+ + Please specify the HTTP endpoint or Lambda identifier: + + } + /> + + Please specify the HTTP endpoint or Lambda identifier: + + } + /> +
+ setType(value as 'uri' | 'arn')} + > + + + + + + + +
+
+ + + Override existing deployments + +
+ + If selected, it will override any existing deployment with the + same URI/identifier, potentially causing unrecoverable errors in + active invocations. + +
+ {isURI && } + {isLambda && } + +
+
+ {children} + + ); +} + +export function RegistrationForm({ + children, +}: { + children: (props: { + isDryRun: boolean; + isPending: boolean; + formId: string; + setIsDryRun: (value: boolean) => void; + error?: { + message: string; + restate_code?: string | null; + } | null; + }) => ReactNode; +}) { + const formId = useId(); + const { close } = useDialog(); + const { refetch } = useListDeployments(); + const [isDryRun, setIsDryRun] = useState(true); + const { mutate, isPending, error, data, reset } = useRegisterDeployment({ + onSuccess: (data, variables) => { + setIsDryRun(false); + if (variables.body?.dry_run === false) { + refetch(); + close(); + } + }, + }); + + useEffect(() => { + return () => { + reset(); + }; + }, [reset]); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const uri = String(formData.get('uri')); + const arn = String(formData.get('arn')); + const type = String(formData.get('type')); + const force = formData.get('force') === 'true'; + const use_http_11 = formData.get('use_http_11') === 'true'; + const assume_role_arn = + formData.get('assume_role_arn')?.toString() || undefined; + const keys = formData.getAll('key'); + const values = formData.getAll('value'); + const additional_headers: Record = keys.reduce( + (result, key, index) => { + const value = values.at(index); + if (typeof key === 'string' && typeof value === 'string' && key) { + return { ...result, [key]: value }; + } + return result; + }, + {} + ); + + mutate({ + body: { + ...(type === 'uri' ? { uri, use_http_11 } : { arn, assume_role_arn }), + force, + dry_run: isDryRun, + additional_headers, + }, + }); + } + + return ( +
+ + {!isDryRun && data?.services && ( +
+

+ Deployment {data.id} +

+

+ Below, you will find the list of services and handlers included in + this deployment. Please confirm. +

+ +
+ )} +
+ {children({ isDryRun, isPending, setIsDryRun, error, formId })} +
+ ); +} diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx new file mode 100644 index 00000000..2ffe28a9 --- /dev/null +++ b/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx @@ -0,0 +1,79 @@ +import * as adminApi from '@restate/data-access/admin-api/spec'; +import { Icon, IconName } from '@restate/ui/icons'; + +export function RegisterDeploymentResults({ + services, +}: { + services: adminApi.components['schemas']['ServiceMetadata'][]; +}) { + if (services.length === 0) { + return ( +
+

No services

+

+ This deployment does not expose any services. +

+
+ ); + } + return ( +
+ {services.map((service) => ( + + ))} +
+ ); +} + +function Service({ + service, +}: { + service: adminApi.components['schemas']['ServiceMetadata']; +}) { + return ( +
+
+
+
+ +
+
+
{service.name}
+
+ rev. {service.revision} +
+
+ {service.ty} +
+
+
+
+ Handlers +
+ {service.handlers.map((handler) => ( + + ))} +
+
+ ); +} + +function ServiceHandler({ + handler, +}: { + handler: adminApi.components['schemas']['ServiceMetadata']['handlers'][number]; +}) { + return ( +
+
+
+ +
+
+
{handler.name}
+
+ {handler.ty} +
+
+ ); +} diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx new file mode 100644 index 00000000..712d697e --- /dev/null +++ b/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx @@ -0,0 +1,26 @@ +import { FormFieldCheckbox } from '@restate/ui/form-field'; + +export function UseHTTP11() { + return ( + + + Use HTTP1.1 + +
+ + If selected, discovery will use a client defaulting to{' '} + HTTP1.1. HTTP2 may be used for{' '} + TLS servers advertising HTTP2 support via + ALPN. HTTP1.1 will work only in request-response mode. + +
+ ); +} diff --git a/libs/features/overview-route/src/lib/RestateServer.tsx b/libs/features/overview-route/src/lib/RestateServer.tsx new file mode 100644 index 00000000..557cd732 --- /dev/null +++ b/libs/features/overview-route/src/lib/RestateServer.tsx @@ -0,0 +1,30 @@ +import { Button } from '@restate/ui/button'; +import { PropsWithChildren } from 'react'; + +export function RestateServer({ + className, + children, +}: PropsWithChildren<{ className?: string }>) { + return ( +
+ + {children} +
+ ); +} diff --git a/libs/features/overview-route/src/lib/Service.tsx b/libs/features/overview-route/src/lib/Service.tsx new file mode 100644 index 00000000..9f9a3a39 --- /dev/null +++ b/libs/features/overview-route/src/lib/Service.tsx @@ -0,0 +1,28 @@ +import type { Deployment } from '@restate/data-access/admin-api'; +import { Icon, IconName } from '@restate/ui/icons'; +import { Link } from '@restate/ui/link'; + +export function Service({ + service, +}: { + service: Deployment['services'][number]; +}) { + return ( + +
+
+
+ +
+
+
{service.name}
+
+ rev. {service.revision} +
+
+ + ); +} diff --git a/libs/features/overview-route/src/lib/overview.route.tsx b/libs/features/overview-route/src/lib/overview.route.tsx new file mode 100644 index 00000000..da35fe99 --- /dev/null +++ b/libs/features/overview-route/src/lib/overview.route.tsx @@ -0,0 +1,122 @@ +import { useListDeployments } from '@restate/data-access/admin-api'; +import { RestateServer } from './RestateServer'; +import { Deployment } from './Deployment'; +import { tv } from 'tailwind-variants'; +import { TriggerRegisterDeploymentDialog } from './RegisterDeployment/Dialog'; +import { ServiceDetails } from './Details.tsx/Service'; +import { DeploymentDetails } from './Details.tsx/Deployment'; + +const deploymentsStyles = tv({ + base: 'w-full md:row-start-1 md:col-start-1 grid gap-8 gap-x-20 gap2-x-[calc(8rem+150px)]', + variants: { + isEmpty: { + true: 'gap-0 h-full [grid-template-columns:1fr]', + false: + 'h-fit [grid-template-columns:1fr] md:[grid-template-columns:1fr_150px_1fr]', + }, + }, + defaultVariants: { + isEmpty: false, + }, +}); +const reactServerStyles = tv({ + base: 'justify-center flex md:sticky md:top-[11rem] flex-col items-center w-fit', + variants: { + isEmpty: { + true: 'md:h-[calc(100vh-150px-6rem)] py-8 flex-auto w-full justify-center rounded-xl border bg-gray-200/50 shadow-[inset_0_1px_0px_0px_rgba(0,0,0,0.03)]', + false: + 'h-fit md:max-h-[calc(100vh-150px-6rem)] min-h-[min(100%,calc(100vh-150px-6rem))]', + }, + }, + defaultVariants: { + isEmpty: false, + }, +}); + +function MultipleDeploymentsPlaceholder() { + return ( +
+
+ + Deployment + +
+
+ ); +} + +function OneDeploymentPlaceholder() { + return ( +
+

+ Point Restate to your deployed services so Restate can discover and + register your services and handlers +

+
+ +
+
+ ); +} + +function NoDeploymentPlaceholder() { + return ( +
+

No deployments

+

+ Point Restate to your deployed services so Restate can discover and + register your services and handlers +

+
+ +
+
+ ); +} + +// TODO: refactor layout +function Component() { + const { data, isError, isLoading, isSuccess } = useListDeployments(); + + // Handle isLoading & isError + const deployments = data?.deployments.slice(0, 60) ?? []; + const hasNoDeployment = isSuccess && deployments.length === 0; + + return ( + <> +
+
+
+ {deployments.map((deployment, i) => ( + + ))} +
+ + {hasNoDeployment && } + {deployments.length > 1 && } + +
+ {deployments.map((deployment, i) => ( + + ))} + {deployments.length === 1 && } +
+
+
+ + + + ); +} + +export const overview = { Component }; diff --git a/libs/features/overview-route/src/lib/restate-server.svg b/libs/features/overview-route/src/lib/restate-server.svg new file mode 100644 index 00000000..07318fb6 --- /dev/null +++ b/libs/features/overview-route/src/lib/restate-server.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/libs/features/overview-route/src/lib/types.ts b/libs/features/overview-route/src/lib/types.ts new file mode 100644 index 00000000..dfb0293b --- /dev/null +++ b/libs/features/overview-route/src/lib/types.ts @@ -0,0 +1,15 @@ +import type { Deployment } from '@restate/data-access/admin-api'; + +export type HTTPDeployment = Exclude; +export type LambdaDeployment = Exclude; +export type DeploymentType = 'uri' | 'arn'; +export function isHttpDeployment( + deployment: Deployment +): deployment is HTTPDeployment { + return 'uri' in deployment; +} +export function isLambdaDeployment( + deployment: Deployment +): deployment is LambdaDeployment { + return 'arn' in deployment; +} diff --git a/libs/features/overview-route/tsconfig.json b/libs/features/overview-route/tsconfig.json new file mode 100644 index 00000000..4daaf45c --- /dev/null +++ b/libs/features/overview-route/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/features/overview-route/tsconfig.lib.json b/libs/features/overview-route/tsconfig.lib.json new file mode 100644 index 00000000..8baf71b1 --- /dev/null +++ b/libs/features/overview-route/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/features/overview-route/tsconfig.spec.json b/libs/features/overview-route/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/features/overview-route/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/features/overview-route/vite.config.ts b/libs/features/overview-route/vite.config.ts new file mode 100644 index 00000000..e281200f --- /dev/null +++ b/libs/features/overview-route/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/features/overview-route', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + watch: false, + globals: true, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/features/overview-route', + provider: 'v8', + }, + }, +}); diff --git a/libs/ui/button/.babelrc b/libs/ui/button/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/button/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/button/.eslintrc.json b/libs/ui/button/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/ui/button/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/ui/button/README.md b/libs/ui/button/README.md new file mode 100644 index 00000000..ce86b388 --- /dev/null +++ b/libs/ui/button/README.md @@ -0,0 +1,7 @@ +# ui-button + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test ui-button` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/button/project.json b/libs/ui/button/project.json new file mode 100644 index 00000000..75237409 --- /dev/null +++ b/libs/ui/button/project.json @@ -0,0 +1,9 @@ +{ + "name": "button", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/button/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project ui-button --web", + "targets": {} +} diff --git a/libs/ui/button/src/index.ts b/libs/ui/button/src/index.ts new file mode 100644 index 00000000..5adaee4d --- /dev/null +++ b/libs/ui/button/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/Button'; +export * from './lib/SubmitButton'; diff --git a/libs/ui/button/src/lib/Button.spec.tsx b/libs/ui/button/src/lib/Button.spec.tsx new file mode 100644 index 00000000..db234ce5 --- /dev/null +++ b/libs/ui/button/src/lib/Button.spec.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react'; +import { Button } from './Button'; + +describe('Button', () => { + it('should render successfully', () => { + const { baseElement } = render( + ); +} diff --git a/libs/ui/button/src/test-setup.ts b/libs/ui/button/src/test-setup.ts new file mode 100644 index 00000000..85205829 --- /dev/null +++ b/libs/ui/button/src/test-setup.ts @@ -0,0 +1,3 @@ +import { installGlobals } from '@remix-run/node'; +import '@testing-library/jest-dom/matchers'; +installGlobals(); diff --git a/libs/ui/button/tsconfig.json b/libs/ui/button/tsconfig.json new file mode 100644 index 00000000..afbd8a58 --- /dev/null +++ b/libs/ui/button/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/ui/button/tsconfig.lib.json b/libs/ui/button/tsconfig.lib.json new file mode 100644 index 00000000..1454bbe7 --- /dev/null +++ b/libs/ui/button/tsconfig.lib.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/ui/button/tsconfig.spec.json b/libs/ui/button/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/ui/button/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/ui/button/vite.config.ts b/libs/ui/button/vite.config.ts new file mode 100644 index 00000000..074ea223 --- /dev/null +++ b/libs/ui/button/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/ui/button', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + setupFiles: ['./src/test-setup.ts'], + globals: true, + cache: { dir: '../../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { reportsDirectory: '../../../coverage/libs/ui/button', provider: 'v8' }, + }, +}); diff --git a/libs/ui/code/.babelrc b/libs/ui/code/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/code/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/code/.eslintrc.json b/libs/ui/code/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/ui/code/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/ui/code/README.md b/libs/ui/code/README.md new file mode 100644 index 00000000..9422076d --- /dev/null +++ b/libs/ui/code/README.md @@ -0,0 +1,7 @@ +# code + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test code` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/code/project.json b/libs/ui/code/project.json new file mode 100644 index 00000000..2f72871b --- /dev/null +++ b/libs/ui/code/project.json @@ -0,0 +1,9 @@ +{ + "name": "code", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/code/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project code --web", + "targets": {} +} diff --git a/libs/ui/code/src/index.ts b/libs/ui/code/src/index.ts new file mode 100644 index 00000000..dc6e3b60 --- /dev/null +++ b/libs/ui/code/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/Code'; +export * from './lib/Snippet'; diff --git a/libs/ui/code/src/lib/Code.tsx b/libs/ui/code/src/lib/Code.tsx new file mode 100644 index 00000000..41c488c8 --- /dev/null +++ b/libs/ui/code/src/lib/Code.tsx @@ -0,0 +1,14 @@ +import { PropsWithChildren } from 'react'; +import { tv } from 'tailwind-variants'; + +interface CodeProps { + className?: string; +} + +const styles = tv({ + base: 'group/code flex flex-col gap-2 gap-y-0 items-stretch font-mono [overflow-wrap:anywhere] rounded-xl border bg-gray-200/50 shadow-[inset_0_1px_0px_0px_rgba(0,0,0,0.03)] text-code p-2 sm:py-3 whitespace-break-spaces', +}); + +export function Code({ children, className }: PropsWithChildren) { + return {children}; +} diff --git a/libs/ui/code/src/lib/Snippet.tsx b/libs/ui/code/src/lib/Snippet.tsx new file mode 100644 index 00000000..96a49c9f --- /dev/null +++ b/libs/ui/code/src/lib/Snippet.tsx @@ -0,0 +1,145 @@ +import { Button } from '@restate/ui/button'; +import { Icon, IconName } from '@restate/ui/icons'; +import { Children, PropsWithChildren, ReactNode, memo, useState } from 'react'; +import { tv } from 'tailwind-variants'; +import { syntaxHighlighter } from './SyntaxHighlighter'; +import { Nav, NavButtonItem } from '@restate/ui/nav'; + +interface SnippetProps { + className?: string; + language?: 'typescript' | 'java' | 'json' | 'bash'; +} + +const LANGUAGE_LABEL: Record< + Exclude, + string +> = { + typescript: 'Typescript', + java: 'Java', + json: 'json', + bash: 'bash', +}; + +function SyntaxHighlighter({ + code, + language, +}: { + code: string; + language: Exclude; +}) { + return ( + + ); +} + +const OptimizedSyntaxHighlighter = memo(SyntaxHighlighter); + +const snippetStyles = tv({ + base: 'flex gap-2 gap-x-2 items-start group/snippet p-2 py-0 has-[.copy]:-my-1 has-[.copy]:pr-1 [&:not(:has(.copy))]:group-has-[.copy]/code:pr-16 [&_.copy]:-mr-2', +}); +export function Snippet({ + children, + className, + language = 'bash', +}: PropsWithChildren) { + const childrenArray = Children.toArray(children); + const codes = childrenArray + .filter((child) => typeof child === 'string') + .join(''); + const others = childrenArray.filter((child) => typeof child !== 'string'); + return ( + + + {others} + + ); +} + +interface SnippetCopyProps { + className?: string; + copyText: string; +} + +const snippetCopyStyles = tv({ + base: 'copy flex-shrink-0 flex items-center gap-1 ml-auto p-2 text-xs', +}); +export function SnippetCopy({ + className, + copyText, +}: PropsWithChildren) { + const [isCopied, setIsCopied] = useState(false); + + return ( + + ); +} + +const snippetTabsStyles = tv({ + base: 'relative @container', +}); +export function SnippetTabs({ + children, + className, + languages, + defaultLanguage, +}: { + className?: string; + languages: Exclude[]; + defaultLanguage: Exclude; + children: ( + language: Exclude + ) => ReactNode; +}) { + const [currentLanguage, setCurrentLanguage] = + useState<(typeof languages)[number]>(defaultLanguage); + return ( +
+
+ +
+
{children(currentLanguage)}
+
+ ); +} diff --git a/libs/ui/code/src/lib/SyntaxHighlighter.tsx b/libs/ui/code/src/lib/SyntaxHighlighter.tsx new file mode 100644 index 00000000..8b590b35 --- /dev/null +++ b/libs/ui/code/src/lib/SyntaxHighlighter.tsx @@ -0,0 +1,13 @@ +import hljs from 'highlight.js/lib/core'; +import typescript from 'highlight.js/lib/languages/typescript'; +import java from 'highlight.js/lib/languages/java'; +import bash from 'highlight.js/lib/languages/bash'; +import json from 'highlight.js/lib/languages/json'; +import 'highlight.js/styles/mono-blue.css'; + +hljs.registerLanguage('typescript', typescript); +hljs.registerLanguage('bash', bash); +hljs.registerLanguage('java', java); +hljs.registerLanguage('json', json); + +export const syntaxHighlighter = hljs; diff --git a/libs/ui/code/tsconfig.json b/libs/ui/code/tsconfig.json new file mode 100644 index 00000000..4daaf45c --- /dev/null +++ b/libs/ui/code/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/ui/code/tsconfig.lib.json b/libs/ui/code/tsconfig.lib.json new file mode 100644 index 00000000..8baf71b1 --- /dev/null +++ b/libs/ui/code/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/ui/code/tsconfig.spec.json b/libs/ui/code/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/ui/code/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/ui/code/vite.config.ts b/libs/ui/code/vite.config.ts new file mode 100644 index 00000000..149b8953 --- /dev/null +++ b/libs/ui/code/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/ui/code', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + globals: true, + cache: { dir: '../../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/ui/code', + provider: 'v8', + }, + }, +}); diff --git a/libs/ui/details/.babelrc b/libs/ui/details/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/details/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/details/.eslintrc.json b/libs/ui/details/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/ui/details/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/ui/details/README.md b/libs/ui/details/README.md new file mode 100644 index 00000000..5d50192c --- /dev/null +++ b/libs/ui/details/README.md @@ -0,0 +1,7 @@ +# details + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test details` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/details/project.json b/libs/ui/details/project.json new file mode 100644 index 00000000..b8db241c --- /dev/null +++ b/libs/ui/details/project.json @@ -0,0 +1,9 @@ +{ + "name": "details", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/details/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project details --web", + "targets": {} +} diff --git a/libs/ui/details/src/index.ts b/libs/ui/details/src/index.ts new file mode 100644 index 00000000..5f1dc91d --- /dev/null +++ b/libs/ui/details/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/Details'; +export * from './lib/Summary'; diff --git a/libs/ui/details/src/lib/Details.tsx b/libs/ui/details/src/lib/Details.tsx new file mode 100644 index 00000000..7365d960 --- /dev/null +++ b/libs/ui/details/src/lib/Details.tsx @@ -0,0 +1,49 @@ +import { Children, PropsWithChildren, useId } from 'react'; +import { tv } from 'tailwind-variants'; +import { DetailsProvider } from './DetailsContext'; +import { isSummary } from './Summary'; + +interface DetailsProps { + open?: boolean; + className?: string; + disabled?: boolean; +} + +const styles = tv({ + base: 'group bg-white rounded-xl border text-gray-800 shadow-sm p-1 has-[+details]:rounded-b-none has-[+details]:border-b-0 [&+details]:rounded-t-none [&:not([open]):has(+details)>summary]:rounded-b-none [&[open]>summary]:rounded-b-none [&+details>summary]:rounded-t-none', + variants: { + isDisabled: { + false: '', + true: '[&>summary]:pointer-events-none cursor-not-allowed', + }, + }, + defaultVariants: { + isDisabled: false, + }, +}); + +export function Details({ + children, + open, + className, + disabled, +}: PropsWithChildren) { + const id = useId(); + const childrenArray = Children.toArray(children); + const summary = childrenArray.filter(isSummary); + const detailChildren = childrenArray.filter((child) => !isSummary(child)); + + return ( + +
+ {summary} +
+ {detailChildren} +
+
+
+ ); +} diff --git a/libs/ui/details/src/lib/DetailsContext.tsx b/libs/ui/details/src/lib/DetailsContext.tsx new file mode 100644 index 00000000..a4c3621a --- /dev/null +++ b/libs/ui/details/src/lib/DetailsContext.tsx @@ -0,0 +1,18 @@ +import { PropsWithChildren, createContext, useContext } from 'react'; + +const DetailsContext = createContext({ id: '' }); + +export function DetailsProvider({ + id, + children, +}: PropsWithChildren<{ id: string }>) { + return ( + {children} + ); +} + +export function useSummaryElement() { + const { id } = useContext(DetailsContext); + const element = document.getElementById(id); + return element; +} diff --git a/libs/ui/details/src/lib/Summary.tsx b/libs/ui/details/src/lib/Summary.tsx new file mode 100644 index 00000000..38befefa --- /dev/null +++ b/libs/ui/details/src/lib/Summary.tsx @@ -0,0 +1,51 @@ +import { Children, PropsWithChildren } from 'react'; +import { usePress } from '@react-aria/interactions'; +import { useFocusRing } from 'react-aria'; +import { focusRing } from '@restate/ui/focus'; +import { tv } from 'tailwind-variants'; +import { Icon, IconName } from '@restate/ui/icons'; + +interface SummaryProps { + className?: string; +} + +const summaryStyles = tv({ + extend: focusRing, + base: 'flex gap-2 px-3 py-2 pressed:bg-gray-200 hover:bg-gray-100 rounded-[calc(.75rem_-_1px_-.25rem)] list-none group-open:mb-2 pr-2.5 [&::-webkit-details-marker]:hidden cursor-default', +}); + +export function Summary({ + children, + className, +}: PropsWithChildren) { + // const element = useSummaryElement(); + const { pressProps, isPressed } = usePress({ + onPress: (event) => { + if (event.pointerType === 'keyboard') { + const details = event.target.closest('details'); + if (details instanceof HTMLDetailsElement) { + details.open = !details.open; + } + } + }, + }); + const { isFocusVisible, focusProps } = useFocusRing(); + return ( + +
{children}
+ +
+ ); +} + +export function isSummary(child: ReturnType[number]) { + return typeof child === 'object' && 'type' in child && child.type === Summary; +} diff --git a/libs/ui/details/tsconfig.json b/libs/ui/details/tsconfig.json new file mode 100644 index 00000000..4daaf45c --- /dev/null +++ b/libs/ui/details/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/ui/details/tsconfig.lib.json b/libs/ui/details/tsconfig.lib.json new file mode 100644 index 00000000..8baf71b1 --- /dev/null +++ b/libs/ui/details/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/ui/details/tsconfig.spec.json b/libs/ui/details/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/ui/details/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/ui/details/vite.config.ts b/libs/ui/details/vite.config.ts new file mode 100644 index 00000000..25024931 --- /dev/null +++ b/libs/ui/details/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/ui/details', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + globals: true, + cache: { dir: '../../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/ui/details', + provider: 'v8', + }, + }, +}); diff --git a/libs/ui/dialog/.babelrc b/libs/ui/dialog/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/dialog/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/dialog/.eslintrc.json b/libs/ui/dialog/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/ui/dialog/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/ui/dialog/README.md b/libs/ui/dialog/README.md new file mode 100644 index 00000000..8f2c7425 --- /dev/null +++ b/libs/ui/dialog/README.md @@ -0,0 +1,7 @@ +# ui-dialog + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test ui-dialog` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/dialog/project.json b/libs/ui/dialog/project.json new file mode 100644 index 00000000..d2ebdf9d --- /dev/null +++ b/libs/ui/dialog/project.json @@ -0,0 +1,9 @@ +{ + "name": "dialog", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/dialog/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project ui-dialog --web", + "targets": {} +} diff --git a/libs/ui/dialog/src/index.ts b/libs/ui/dialog/src/index.ts new file mode 100644 index 00000000..badcc051 --- /dev/null +++ b/libs/ui/dialog/src/index.ts @@ -0,0 +1,6 @@ +export * from './lib/Dialog'; +export * from './lib/DialogContent'; +export * from './lib/DialogTrigger'; +export * from './lib/DialogClose'; +export * from './lib/useDialog'; +export { DialogFooter } from './lib/DialogFooter'; diff --git a/libs/ui/dialog/src/lib/Dialog.tsx b/libs/ui/dialog/src/lib/Dialog.tsx new file mode 100644 index 00000000..f98ef782 --- /dev/null +++ b/libs/ui/dialog/src/lib/Dialog.tsx @@ -0,0 +1,19 @@ +import { PropsWithChildren } from 'react'; +import { DialogTrigger } from 'react-aria-components'; + +interface DialogProps { + open?: boolean; + onOpenChange?: (isOpen: boolean) => void; +} + +export function Dialog({ + children, + open, + onOpenChange, +}: PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/libs/ui/dialog/src/lib/DialogClose.tsx b/libs/ui/dialog/src/lib/DialogClose.tsx new file mode 100644 index 00000000..2a1a992b --- /dev/null +++ b/libs/ui/dialog/src/lib/DialogClose.tsx @@ -0,0 +1,21 @@ +import { ComponentProps, useContext } from 'react'; +import { OverlayTriggerStateContext } from 'react-aria-components'; +import { Pressable, PressResponder } from '@react-aria/interactions'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface DialogCLoseProps { + children: ComponentProps['children']; +} + +export function DialogClose({ children }: DialogCLoseProps) { + const state = useContext(OverlayTriggerStateContext); + return ( + { + state.close(); + }} + > + {children} + + ); +} diff --git a/libs/ui/dialog/src/lib/DialogContent.tsx b/libs/ui/dialog/src/lib/DialogContent.tsx new file mode 100644 index 00000000..9f8a336f --- /dev/null +++ b/libs/ui/dialog/src/lib/DialogContent.tsx @@ -0,0 +1,61 @@ +import type { PropsWithChildren } from 'react'; +import { + Dialog as AriaDialog, + Modal as AriaModal, + ModalOverlay as AriaModalOverlay, + composeRenderProps, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; +import { DialogFooterContainer } from './DialogFooter'; + +const overlayStyles = tv({ + base: 'fixed top-0 left-0 w-full isolate z-50 bg-gray-800 bg-opacity-30 transition-opacity flex items-center justify-center p-4 text-center [height:100vh] [min-height:100vh]', + variants: { + isEntering: { + true: 'animate-in fade-in duration-200 ease-out', + }, + isExiting: { + true: 'animate-out fade-out duration-200 ease-in', + }, + }, +}); + +const modalStyles = tv({ + base: 'flex w-full max-w-sm max-h-full overflow-auto rounded-[1.125rem] [clip-path:inset(0_0_0_0_round_1.125rem)] bg-white text-left align-middle text-slate-700 shadow-lg shadow-zinc-800/5 border border-black/5', + variants: { + isEntering: { + true: 'animate-in zoom-in-105 ease-out duration-200', + }, + isExiting: { + true: 'animate-out zoom-out-95 ease-in duration-200', + }, + }, +}); + +interface DialogContentProps { + className?: string; +} + +export function DialogContent({ + children, + className, +}: PropsWithChildren) { + return ( + + + modalStyles({ ...renderProps, className }) + )} + > + + +
+ {children} +
+
+
+
+
+ ); +} diff --git a/libs/ui/dialog/src/lib/DialogFooter.tsx b/libs/ui/dialog/src/lib/DialogFooter.tsx new file mode 100644 index 00000000..4edb5b20 --- /dev/null +++ b/libs/ui/dialog/src/lib/DialogFooter.tsx @@ -0,0 +1,41 @@ +import { + PropsWithChildren, + createContext, + useContext, + useId, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + +const DialogFooterContext = createContext<{ container: HTMLElement | null }>({ + container: null, +}); + +export function DialogFooterContainer({ children }: PropsWithChildren) { + const id = useId(); + const [container, setContainer] = useState(null); + + return ( + + {children} +
+ + ); +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface DialogFooterProps {} +export function DialogFooter({ + children, +}: PropsWithChildren) { + const { container } = useContext(DialogFooterContext); + + if (container) { + return createPortal(children, container); + } + return null; +} diff --git a/libs/ui/dialog/src/lib/DialogTrigger.tsx b/libs/ui/dialog/src/lib/DialogTrigger.tsx new file mode 100644 index 00000000..e79c5e51 --- /dev/null +++ b/libs/ui/dialog/src/lib/DialogTrigger.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface DialogTriggerProps {} +export function DialogTrigger({ + children, +}: PropsWithChildren) { + return children; +} diff --git a/libs/ui/dialog/src/lib/useDialog.ts b/libs/ui/dialog/src/lib/useDialog.ts new file mode 100644 index 00000000..7076c0e9 --- /dev/null +++ b/libs/ui/dialog/src/lib/useDialog.ts @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import { OverlayTriggerStateContext } from 'react-aria-components'; + +export function useDialog() { + const { open, close, isOpen } = useContext(OverlayTriggerStateContext); + + return { open, close, isOpen }; +} diff --git a/libs/ui/dialog/tsconfig.json b/libs/ui/dialog/tsconfig.json new file mode 100644 index 00000000..4daaf45c --- /dev/null +++ b/libs/ui/dialog/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/ui/dialog/tsconfig.lib.json b/libs/ui/dialog/tsconfig.lib.json new file mode 100644 index 00000000..8baf71b1 --- /dev/null +++ b/libs/ui/dialog/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/ui/dialog/tsconfig.spec.json b/libs/ui/dialog/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/ui/dialog/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/ui/dialog/vite.config.ts b/libs/ui/dialog/vite.config.ts new file mode 100644 index 00000000..db44a463 --- /dev/null +++ b/libs/ui/dialog/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/ui/modal', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + globals: true, + cache: { dir: '../../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/ui/modal', + provider: 'v8', + }, + }, +}); diff --git a/libs/ui/dropdown/.babelrc b/libs/ui/dropdown/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/dropdown/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/dropdown/.eslintrc.json b/libs/ui/dropdown/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/ui/dropdown/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/ui/dropdown/README.md b/libs/ui/dropdown/README.md new file mode 100644 index 00000000..75a68b9f --- /dev/null +++ b/libs/ui/dropdown/README.md @@ -0,0 +1,7 @@ +# ui-dropdown + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test ui-dropdown` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/dropdown/project.json b/libs/ui/dropdown/project.json new file mode 100644 index 00000000..eb692e94 --- /dev/null +++ b/libs/ui/dropdown/project.json @@ -0,0 +1,9 @@ +{ + "name": "dropdown", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/dropdown/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project ui-dropdown --web", + "targets": {} +} diff --git a/libs/ui/dropdown/src/index.ts b/libs/ui/dropdown/src/index.ts new file mode 100644 index 00000000..f83df1f3 --- /dev/null +++ b/libs/ui/dropdown/src/index.ts @@ -0,0 +1,7 @@ +export { DropdownMenu } from './lib/DropdownMenu'; +export { DropdownItem } from './lib/DropdownItem'; +export { DropdownSection } from './lib/DropdownSection'; +export { DropdownSeparator } from './lib/DropdownSeparator'; +export { DropdownTrigger } from './lib/DropdownTrigger'; +export { DropdownPopover } from './lib/DropdownPopover'; +export { Dropdown } from './lib/Dropdown'; diff --git a/libs/ui/dropdown/src/lib/Dropdown.tsx b/libs/ui/dropdown/src/lib/Dropdown.tsx new file mode 100644 index 00000000..d781f8ec --- /dev/null +++ b/libs/ui/dropdown/src/lib/Dropdown.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from 'react'; +import { MenuTrigger } from 'react-aria-components'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface DropdownProps {} + +export function Dropdown({ children }: PropsWithChildren) { + return {children}; +} diff --git a/libs/ui/dropdown/src/lib/DropdownItem.tsx b/libs/ui/dropdown/src/lib/DropdownItem.tsx new file mode 100644 index 00000000..7c4d4a36 --- /dev/null +++ b/libs/ui/dropdown/src/lib/DropdownItem.tsx @@ -0,0 +1,113 @@ +import { Icon, IconName } from '@restate/ui/icons'; +import type { PropsWithChildren } from 'react'; +import { + MenuItem as AriaMenuItem, + MenuItemProps as AriaMenuItemProps, + composeRenderProps, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; + +const styles = tv({ + base: 'group dropdown-item flex rounded-xl items-center gap-4 cursor-default select-none py-2 px-3 outline outline-0 text-sm forced-color-adjust-none', + variants: { + isDisabled: { + false: 'text-gray-900 dark:text-zinc-100', + true: 'text-gray-300 dark:text-zinc-600 forced-colors:text-[GrayText]', + }, + isFocused: { + true: 'bg-blue-600 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]', + }, + }, +}); + +const destructiveStyles = tv({ + base: 'group dropdown-item flex rounded-xl items-center gap-4 cursor-default select-none py-2 px-3 outline outline-0 text-sm forced-color-adjust-none', + variants: { + isDisabled: { + false: 'text-red-600 dark:text-zinc-100', + true: 'text-gray-300 dark:text-zinc-600 forced-colors:text-[GrayText]', + }, + isFocused: { + true: 'bg-red-600 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]', + }, + }, +}); + +function StyledDropdownItem({ + destructive, + ...props +}: AriaMenuItemProps & { destructive?: boolean }) { + return ( + + {composeRenderProps( + props.children, + (children, { selectionMode, isSelected }) => ( + <> + + {children} + + {selectionMode !== 'none' && ( + + {isSelected && } + + )} + + ) + )} + + ); +} + +interface DropdownItemProps + extends PropsWithChildren<{ + value?: never; + href?: never; + }> { + destructive?: boolean; + className?: string; +} + +interface DropdownCustomItemProps + extends PropsWithChildren< + Omit + > { + value: string; + href?: never; +} + +interface DropdownNavItemProps + extends Omit { + href: string; + value?: string; +} + +function isNavItem( + props: DropdownItemProps | DropdownCustomItemProps | DropdownNavItemProps +): props is DropdownNavItemProps { + return Boolean(props.href); +} + +function isCustomItem( + props: DropdownItemProps | DropdownCustomItemProps | DropdownNavItemProps +): props is DropdownCustomItemProps { + return typeof props.value === 'string'; +} + +export function DropdownItem( + props: DropdownItemProps | DropdownCustomItemProps | DropdownNavItemProps +) { + if (isNavItem(props)) { + const { href, value, ...rest } = props; + return ( + + ); + } + if (isCustomItem(props)) { + const { value, ...rest } = props; + return ; + } + return ; +} diff --git a/libs/ui/dropdown/src/lib/DropdownMenu.tsx b/libs/ui/dropdown/src/lib/DropdownMenu.tsx new file mode 100644 index 00000000..926086b1 --- /dev/null +++ b/libs/ui/dropdown/src/lib/DropdownMenu.tsx @@ -0,0 +1,68 @@ +import { + Menu as AriaMenu, + MenuProps as AriaMenuProps, +} from 'react-aria-components'; +import { PropsWithChildren } from 'react'; +import { tv } from 'tailwind-variants'; + +const styles = tv({ + base: 'p-1 outline outline-0 max-h-[inherit] overflow-auto [clip-path:inset(0_0_0_0_round_.75rem)] [&~.dropdown-menu]:pt-0', +}); +function StyledDropdownMenu({ + className, + ...props +}: AriaMenuProps) { + return ( + + ); +} + +export interface DropdownMenuProps { + disabledItems?: Iterable; + ['aria-label']?: string; + selectable?: never; + selectedItems?: never; + multiple?: never; + onSelect?: (key: string) => void; + className?: string; + autoFocus?: boolean; +} + +export interface SelectableDropdownMenuProps + extends Omit { + multiple?: boolean; + selectedItems?: Iterable; + selectable: true; +} + +export function DropdownMenu({ + multiple, + disabledItems, + selectedItems, + selectable, + onSelect, + className, + ...props +}: PropsWithChildren) { + if (selectable) { + return ( + onSelect?.(String(key))} + className={className} + /> + ); + } else { + return ( + onSelect?.(String(key))} + className={className} + /> + ); + } +} diff --git a/libs/ui/dropdown/src/lib/DropdownPopover.tsx b/libs/ui/dropdown/src/lib/DropdownPopover.tsx new file mode 100644 index 00000000..2c487836 --- /dev/null +++ b/libs/ui/dropdown/src/lib/DropdownPopover.tsx @@ -0,0 +1,22 @@ +import { PopoverContent } from '@restate/ui/popover'; +import type { PropsWithChildren } from 'react'; +import { tv } from 'tailwind-variants'; + +interface DropdownPopoverProps { + className?: string; +} + +const styles = tv({ + base: 'min-w-[150px]', +}); + +export function DropdownPopover({ + children, + className, +}: PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/libs/ui/dropdown/src/lib/DropdownSection.tsx b/libs/ui/dropdown/src/lib/DropdownSection.tsx new file mode 100644 index 00000000..356b438f --- /dev/null +++ b/libs/ui/dropdown/src/lib/DropdownSection.tsx @@ -0,0 +1,30 @@ +import { PropsWithChildren } from 'react'; +import { Header } from 'react-aria-components'; +import { tv } from 'tailwind-variants'; + +export interface DropdownSectionProps extends PropsWithChildren { + title?: string; + className?: string; +} + +const styles = tv({ + slots: { + container: 'px-1', + header: 'text-sm font-semibold text-gray-500 px-4 py-1 pt-2 truncate', + menu: 'bg-white rounded-xl border [&_.dropdown-item]:rounded-lg', + }, +}); +export function DropdownSection({ + children, + title, + className, +}: DropdownSectionProps) { + const { container, menu, header } = styles(); + // TODO: fix accessibility of header and section + return ( +
+ {title &&
{title}
} +
{children}
+
+ ); +} diff --git a/libs/ui/dropdown/src/lib/DropdownSeparator.tsx b/libs/ui/dropdown/src/lib/DropdownSeparator.tsx new file mode 100644 index 00000000..1263138b --- /dev/null +++ b/libs/ui/dropdown/src/lib/DropdownSeparator.tsx @@ -0,0 +1,5 @@ +import { Separator } from 'react-aria-components'; + +export function DropdownSeparator() { + return ; +} diff --git a/libs/ui/dropdown/src/lib/DropdownTrigger.tsx b/libs/ui/dropdown/src/lib/DropdownTrigger.tsx new file mode 100644 index 00000000..8289e006 --- /dev/null +++ b/libs/ui/dropdown/src/lib/DropdownTrigger.tsx @@ -0,0 +1,10 @@ +import type { PropsWithChildren } from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface DropdownTriggerProps {} + +export function DropdownTrigger({ + children, +}: PropsWithChildren) { + return children; +} diff --git a/libs/ui/dropdown/tsconfig.json b/libs/ui/dropdown/tsconfig.json new file mode 100644 index 00000000..4daaf45c --- /dev/null +++ b/libs/ui/dropdown/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/ui/dropdown/tsconfig.lib.json b/libs/ui/dropdown/tsconfig.lib.json new file mode 100644 index 00000000..8baf71b1 --- /dev/null +++ b/libs/ui/dropdown/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/ui/dropdown/tsconfig.spec.json b/libs/ui/dropdown/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/ui/dropdown/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/ui/dropdown/vite.config.ts b/libs/ui/dropdown/vite.config.ts new file mode 100644 index 00000000..1362f7f6 --- /dev/null +++ b/libs/ui/dropdown/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/ui/menu', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + globals: true, + cache: { dir: '../../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/ui/menu', + provider: 'v8', + }, + }, +}); diff --git a/libs/ui/error/.babelrc b/libs/ui/error/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/error/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/error/.eslintrc.json b/libs/ui/error/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/ui/error/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/ui/error/README.md b/libs/ui/error/README.md new file mode 100644 index 00000000..301a1eaf --- /dev/null +++ b/libs/ui/error/README.md @@ -0,0 +1,7 @@ +# error + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test error` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/error/project.json b/libs/ui/error/project.json new file mode 100644 index 00000000..6c50d40c --- /dev/null +++ b/libs/ui/error/project.json @@ -0,0 +1,9 @@ +{ + "name": "error", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/error/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project error --web", + "targets": {} +} diff --git a/libs/ui/error/src/index.ts b/libs/ui/error/src/index.ts new file mode 100644 index 00000000..03300822 --- /dev/null +++ b/libs/ui/error/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/ErrorBanner'; +export * from './lib/InlineError'; +export * from './lib/CrashError'; diff --git a/libs/ui/error/src/lib/CrashError.tsx b/libs/ui/error/src/lib/CrashError.tsx new file mode 100644 index 00000000..9f63ab3f --- /dev/null +++ b/libs/ui/error/src/lib/CrashError.tsx @@ -0,0 +1,31 @@ +import { useRouteError } from '@remix-run/react'; +import { UnauthorizedError } from '@restate/util/errors'; +import { Link } from '@restate/ui/link'; + +export function CrashError() { + const error = useRouteError(); + console.error(error); + + if (error instanceof UnauthorizedError) { + return null; + } + + return ( +
+

+ Oops something went wrong! +

+

+ Sorry, we couldn’t load what you’re looking for. +

+
+ + Go back home + + + Contact support + +
+
+ ); +} diff --git a/libs/ui/error/src/lib/ErrorBanner.tsx b/libs/ui/error/src/lib/ErrorBanner.tsx new file mode 100644 index 00000000..da60110b --- /dev/null +++ b/libs/ui/error/src/lib/ErrorBanner.tsx @@ -0,0 +1,98 @@ +import { Icon, IconName } from '@restate/ui/icons'; +import { PropsWithChildren } from 'react'; +import { tv } from 'tailwind-variants'; + +export interface ErrorProps { + errors?: + | Error[] + | string[] + | { + message: string; + restate_code?: string | null; + }[]; + className?: string; +} + +const styles = tv({ + base: 'rounded-xl bg-red-100 p-3', +}); + +function SingleError({ + error, + children, + className, +}: PropsWithChildren<{ + error?: + | Error + | string + | { + message: string; + restate_code?: string | null; + }; + className?: string; +}>) { + if (!error) { + return null; + } + + return ( +
+
+
+ +
+ + {typeof error === 'string' ? error : error.message} + + {children &&
{children}
} +
+
+ ); +} + +export function ErrorBanner({ + errors = [], + children, + className, +}: PropsWithChildren) { + if (errors.length === 0) { + return null; + } + if (errors.length === 1) { + const [error] = errors; + return ( + + ); + } + + return ( +
+
+
+ +
+
+

+ There were {errors.length} errors: +

+ +
    + {errors.map((error) => ( +
  • + {typeof error === 'string' ? error : error.message} +
  • + ))} +
+
+ {children} +
+
+
+ ); +} diff --git a/libs/ui/error/src/lib/InlineError.tsx b/libs/ui/error/src/lib/InlineError.tsx new file mode 100644 index 00000000..dda9cca1 --- /dev/null +++ b/libs/ui/error/src/lib/InlineError.tsx @@ -0,0 +1,25 @@ +import { Icon, IconName } from '@restate/ui/icons'; +import { PropsWithChildren } from 'react'; +import { tv } from 'tailwind-variants'; + +export interface InlineErrorProps { + className?: string; +} + +const styles = tv({ + base: 'inline-flex gap-1 items-center text-start text-red-600', +}); +export function InlineError({ + children, + className, +}: PropsWithChildren) { + return ( + + + {children} + + ); +} diff --git a/libs/ui/error/tsconfig.json b/libs/ui/error/tsconfig.json new file mode 100644 index 00000000..4daaf45c --- /dev/null +++ b/libs/ui/error/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/ui/error/tsconfig.lib.json b/libs/ui/error/tsconfig.lib.json new file mode 100644 index 00000000..fc5856c4 --- /dev/null +++ b/libs/ui/error/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "../../../@types/global-env.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/ui/error/tsconfig.spec.json b/libs/ui/error/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/ui/error/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/ui/error/vite.config.ts b/libs/ui/error/vite.config.ts new file mode 100644 index 00000000..842df1e6 --- /dev/null +++ b/libs/ui/error/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/ui/error', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + globals: true, + cache: { dir: '../../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/ui/error', + provider: 'v8', + }, + }, +}); diff --git a/libs/ui/focus/.babelrc b/libs/ui/focus/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/focus/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/focus/.eslintrc.json b/libs/ui/focus/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/ui/focus/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/ui/focus/README.md b/libs/ui/focus/README.md new file mode 100644 index 00000000..ddac937f --- /dev/null +++ b/libs/ui/focus/README.md @@ -0,0 +1,7 @@ +# ui-focus + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test ui-focus` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/focus/project.json b/libs/ui/focus/project.json new file mode 100644 index 00000000..352a8619 --- /dev/null +++ b/libs/ui/focus/project.json @@ -0,0 +1,9 @@ +{ + "name": "focus", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/focus/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project ui-focus --web", + "targets": {} +} diff --git a/libs/ui/focus/src/index.ts b/libs/ui/focus/src/index.ts new file mode 100644 index 00000000..10c246a3 --- /dev/null +++ b/libs/ui/focus/src/index.ts @@ -0,0 +1 @@ +export * from './lib/focus'; diff --git a/libs/ui/focus/src/lib/focus.tsx b/libs/ui/focus/src/lib/focus.tsx new file mode 100644 index 00000000..b5872e95 --- /dev/null +++ b/libs/ui/focus/src/lib/focus.tsx @@ -0,0 +1,11 @@ +import { tv } from 'tailwind-variants'; + +export const focusRing = tv({ + base: 'outline outline-blue-600 outline-offset-2', + variants: { + isFocusVisible: { + false: 'outline-0', + true: 'outline-2', + }, + }, +}); diff --git a/libs/ui/focus/tsconfig.json b/libs/ui/focus/tsconfig.json new file mode 100644 index 00000000..4daaf45c --- /dev/null +++ b/libs/ui/focus/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/ui/focus/tsconfig.lib.json b/libs/ui/focus/tsconfig.lib.json new file mode 100644 index 00000000..8baf71b1 --- /dev/null +++ b/libs/ui/focus/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/ui/focus/tsconfig.spec.json b/libs/ui/focus/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/ui/focus/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/ui/focus/vite.config.ts b/libs/ui/focus/vite.config.ts new file mode 100644 index 00000000..6799f354 --- /dev/null +++ b/libs/ui/focus/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/ui/focus', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + globals: true, + cache: { dir: '../../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/ui/focus', + provider: 'v8', + }, + }, +}); diff --git a/libs/ui/form-field/.babelrc b/libs/ui/form-field/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/form-field/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/form-field/.eslintrc.json b/libs/ui/form-field/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/ui/form-field/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/ui/form-field/README.md b/libs/ui/form-field/README.md new file mode 100644 index 00000000..be801e2e --- /dev/null +++ b/libs/ui/form-field/README.md @@ -0,0 +1,7 @@ +# ui-form-field + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test ui-form-field` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/form-field/project.json b/libs/ui/form-field/project.json new file mode 100644 index 00000000..23c8866b --- /dev/null +++ b/libs/ui/form-field/project.json @@ -0,0 +1,9 @@ +{ + "name": "form-field", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/form-field/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project ui-form-field --web", + "targets": {} +} diff --git a/libs/ui/form-field/src/index.ts b/libs/ui/form-field/src/index.ts new file mode 100644 index 00000000..b4b0fe43 --- /dev/null +++ b/libs/ui/form-field/src/index.ts @@ -0,0 +1,7 @@ +export * from './lib/FormFieldError'; +export * from './lib/FormFieldGroup'; +export * from './lib/FormFieldLabel'; +export * from './lib/FormFieldInput'; +export * from './lib/FormFieldTextarea'; +export * from './lib/FormFieldCheckbox'; +export * from './lib/FormFieldSelect'; diff --git a/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx b/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx new file mode 100644 index 00000000..3d7aa2ad --- /dev/null +++ b/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx @@ -0,0 +1,72 @@ +import { + TextFieldProps as AriaTextFieldProps, + Input, + TextField, + Label, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; +import { FormFieldError } from './FormFieldError'; +import { ComponentProps, PropsWithChildren, forwardRef } from 'react'; + +interface FormFieldCheckboxProps + extends Pick< + AriaTextFieldProps, + 'name' | 'value' | 'defaultValue' | 'autoFocus' + > { + className?: string; + required?: boolean; + disabled?: boolean; + errorMessage?: ComponentProps['children']; + slot?: string; + checked?: boolean; + direction?: 'left' | 'right'; +} + +const styles = tv({ + slots: { + label: 'row-start-1 text-base', + container: 'grid gap-x-2 items-center', + input: + 'disabled:text-gray-100 hover:disabled:text-gray-100 focus:disabled:text-gray-100 disabled:bg-gray-100 disabled:border-gray-100 disabled:shadow-none invalid:bg-red-100 invalid:border-red-600 text-blue-600 checked:focus:text-blue-800 bg-gray-100 row-start-1 min-w-0 rounded-md w-5 h-5 border-gray-200 focus:bg-gray-300 hover:bg-gray-300 shadow-[inset_0_0.5px_0.5px_0px_rgba(0,0,0,0.08)]', + }, + variants: { + direction: { + left: { + container: 'grid-cols-[1.25rem_1fr]', + input: 'col-start-1', + label: 'col-start-2', + }, + right: { + container: 'grid-cols-[1fr_1.25rem]', + input: 'col-start-2', + label: 'col-start-1', + }, + }, + }, +}); +export const FormFieldCheckbox = forwardRef< + HTMLInputElement, + PropsWithChildren +>( + ( + { className, errorMessage, children, direction = 'left', ...props }, + ref + ) => { + const { input, container, label } = styles({ direction }); + return ( + + + + + + ); + } +); diff --git a/libs/ui/form-field/src/lib/FormFieldError.tsx b/libs/ui/form-field/src/lib/FormFieldError.tsx new file mode 100644 index 00000000..430834d8 --- /dev/null +++ b/libs/ui/form-field/src/lib/FormFieldError.tsx @@ -0,0 +1,16 @@ +import { + FieldError as AriaFieldError, + FieldErrorProps, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; + +interface FormFieldErrorProps extends Pick { + className?: string; +} + +const styles = tv({ + base: 'text-xs px-1 pt-0.5 text-red-600 forced-colors:text-[Mark]', +}); +export function FormFieldError({ className, ...props }: FormFieldErrorProps) { + return ; +} diff --git a/libs/ui/form-field/src/lib/FormFieldGroup.tsx b/libs/ui/form-field/src/lib/FormFieldGroup.tsx new file mode 100644 index 00000000..7ca407a7 --- /dev/null +++ b/libs/ui/form-field/src/lib/FormFieldGroup.tsx @@ -0,0 +1,43 @@ +import { focusRing } from '@restate/ui/focus'; +import { tv } from 'tailwind-variants'; +import { Group as AriaGroup } from 'react-aria-components'; +import { PropsWithChildren } from 'react'; + +interface FormFieldGroupProps { + className?: string; +} + +const fieldBorderStyles = tv({ + variants: { + isFocusWithin: { + false: 'border-gray-300 dark:border-zinc-500', + true: 'border-gray-600 dark:border-zinc-300 rounded-[0.625rem] outline-offset-8', + }, + isInvalid: { + true: 'border-red-600 dark:border-red-600 forced-colors:border-[Mark]', + }, + isDisabled: { + true: 'border-gray-200 dark:border-zinc-700 forced-colors:border-[GrayText]', + }, + }, +}); + +const fieldGroupStyles = tv({ + extend: focusRing, + base: 'group flex items-start flex-col', + variants: fieldBorderStyles.variants, +}); + +export function FormFieldGroup({ + className, + ...props +}: PropsWithChildren) { + return ( + + fieldGroupStyles({ ...renderProps, className }) + } + /> + ); +} diff --git a/libs/ui/form-field/src/lib/FormFieldInput.tsx b/libs/ui/form-field/src/lib/FormFieldInput.tsx new file mode 100644 index 00000000..9e0e2b33 --- /dev/null +++ b/libs/ui/form-field/src/lib/FormFieldInput.tsx @@ -0,0 +1,71 @@ +import { + TextFieldProps as AriaTextFieldProps, + Input as AriaInput, + TextField, + Label, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; +import { FormFieldError } from './FormFieldError'; +import { ComponentProps, ReactNode } from 'react'; +import { FormFieldLabel } from './FormFieldLabel'; + +const inputStyles = tv({ + base: 'invalid:border-red-600 invalid:bg-red-100/70 focus:outline focus:border-gray-200 disabled:text-gray-500/80 disabled:placeholder:text-gray-300 disabled:border-gray-100 disabled:shadow-none [&[readonly]]:text-gray-500/80 [&[readonly]]:bg-gray-100 read-only:shadow-none focus:shadow-none focus:outline-blue-600 focus:[box-shadow:inset_0_1px_0px_0px_rgba(0,0,0,0.03)] shadow-[inset_0_1px_0px_0px_rgba(0,0,0,0.03)] mt-0 bg-gray-100 rounded-lg border border-gray-200 py-1.5 placeholder:text-gray-500/70 px-2 w-full min-w-0 text-sm text-gray-900', +}); +const containerStyles = tv({ + base: '', +}); + +interface InputProps + extends Pick< + AriaTextFieldProps, + | 'name' + | 'value' + | 'defaultValue' + | 'autoFocus' + | 'autoComplete' + | 'validate' + | 'pattern' + | 'maxLength' + | 'type' + | 'onChange' + > { + className?: string; + required?: boolean; + disabled?: boolean; + readonly?: boolean; + placeholder?: string; + label?: ReactNode; + errorMessage?: ComponentProps['children']; +} +export function FormFieldInput({ + className, + required, + disabled, + autoComplete = 'off', + placeholder, + errorMessage, + label, + readonly, + ...props +}: InputProps) { + return ( + + {!label && } + {label && {label}} + + + + ); +} diff --git a/libs/ui/form-field/src/lib/FormFieldLabel.tsx b/libs/ui/form-field/src/lib/FormFieldLabel.tsx new file mode 100644 index 00000000..7205033e --- /dev/null +++ b/libs/ui/form-field/src/lib/FormFieldLabel.tsx @@ -0,0 +1,14 @@ +import { PropsWithChildren } from 'react'; +import { Label as AriaLabel } from 'react-aria-components'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface FormFieldLabelProps {} + +export function FormFieldLabel(props: PropsWithChildren) { + return ( + + ); +} diff --git a/libs/ui/form-field/src/lib/FormFieldSelect.tsx b/libs/ui/form-field/src/lib/FormFieldSelect.tsx new file mode 100644 index 00000000..59af4a22 --- /dev/null +++ b/libs/ui/form-field/src/lib/FormFieldSelect.tsx @@ -0,0 +1,87 @@ +import { + SelectProps as AriaSelectProps, + Label, + Select, + SelectValue, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; +import { FormFieldError } from './FormFieldError'; +import { ComponentProps, PropsWithChildren, ReactNode } from 'react'; +import { Button } from '@restate/ui/button'; +import { PopoverOverlay } from '@restate/ui/popover'; +import { ListBox, ListBoxItem } from '@restate/ui/listbox'; +import { FormFieldLabel } from './FormFieldLabel'; +import { Icon, IconName } from '@restate/ui/icons'; + +const containerStyles = tv({ + base: '', +}); + +interface SelectProps + extends Pick< + AriaSelectProps, + 'name' | 'autoFocus' | 'autoComplete' | 'validate' + > { + className?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; + errorMessage?: ComponentProps['children']; + label?: ReactNode; +} +export function FormFieldSelect({ + className, + required, + disabled, + autoComplete = 'off', + placeholder, + errorMessage, + children, + label, + autoFocus, + ...props +}: PropsWithChildren) { + return ( + + ); +} + +export function Option({ children }: { children: string }) { + return ( + + {children} + + ); +} diff --git a/libs/ui/form-field/src/lib/FormFieldTextarea.tsx b/libs/ui/form-field/src/lib/FormFieldTextarea.tsx new file mode 100644 index 00000000..fee574f9 --- /dev/null +++ b/libs/ui/form-field/src/lib/FormFieldTextarea.tsx @@ -0,0 +1,61 @@ +import { + TextAreaProps as AriaTextAreaProps, + TextArea as AriaTextArea, + TextField, + Label, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; +import { FormFieldError } from './FormFieldError'; +import { ComponentProps, ReactNode } from 'react'; +import { FormFieldLabel } from './FormFieldLabel'; + +const inputStyles = tv({ + base: 'flex-1 invalid:border-red-600 invalid:bg-red-100/70 focus:outline focus:border-gray-200 disabled:text-gray-500/80 disabled:placeholder:text-gray-300 disabled:bg-gray-100 disabled:border-gray-100 disabled:shadow-none focus:shadow-none focus:outline-blue-600 focus:[box-shadow:inset_0_1px_0px_0px_rgba(0,0,0,0.03)] shadow-[inset_0_1px_0px_0px_rgba(0,0,0,0.03)] mt-0 bg-gray-100 rounded-lg border border-gray-200 py-1.5 placeholder:text-gray-500/70 px-2 w-full min-w-0 text-sm text-gray-900', +}); +const containerStyles = tv({ + base: 'flex flex-col', +}); + +interface FormFieldTextarea + extends Pick< + AriaTextAreaProps, + 'name' | 'autoFocus' | 'autoComplete' | 'maxLength' | 'rows' + > { + className?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; + label?: ReactNode; + errorMessage?: ComponentProps['children']; + value?: string; + defaultValue?: string; +} +export function FormFieldTextarea({ + className, + required, + disabled, + autoComplete = 'off', + placeholder, + errorMessage, + label, + ...props +}: FormFieldTextarea) { + return ( + + {!label && } + {label && {label}} + + + + ); +} diff --git a/libs/ui/form-field/tsconfig.json b/libs/ui/form-field/tsconfig.json new file mode 100644 index 00000000..4daaf45c --- /dev/null +++ b/libs/ui/form-field/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/ui/form-field/tsconfig.lib.json b/libs/ui/form-field/tsconfig.lib.json new file mode 100644 index 00000000..8baf71b1 --- /dev/null +++ b/libs/ui/form-field/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/ui/form-field/tsconfig.spec.json b/libs/ui/form-field/tsconfig.spec.json new file mode 100644 index 00000000..05a0e183 --- /dev/null +++ b/libs/ui/form-field/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/ui/form-field/vite.config.ts b/libs/ui/form-field/vite.config.ts new file mode 100644 index 00000000..8fb8559e --- /dev/null +++ b/libs/ui/form-field/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/ui/form-field', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + globals: true, + cache: { dir: '../../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/ui/form-field', + provider: 'v8', + }, + }, +}); diff --git a/libs/ui/icons/.babelrc b/libs/ui/icons/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/ui/icons/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/ui/icons/.eslintrc.json b/libs/ui/icons/.eslintrc.json new file mode 100644 index 00000000..75b85077 --- /dev/null +++ b/libs/ui/icons/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/ui/icons/README.md b/libs/ui/icons/README.md new file mode 100644 index 00000000..5b625c02 --- /dev/null +++ b/libs/ui/icons/README.md @@ -0,0 +1,7 @@ +# ui-icons + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test ui-icons` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/libs/ui/icons/project.json b/libs/ui/icons/project.json new file mode 100644 index 00000000..1e3d0909 --- /dev/null +++ b/libs/ui/icons/project.json @@ -0,0 +1,9 @@ +{ + "name": "icons", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/icons/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project ui-icons --web", + "targets": {} +} diff --git a/libs/ui/icons/src/index.ts b/libs/ui/icons/src/index.ts new file mode 100644 index 00000000..de76a4b2 --- /dev/null +++ b/libs/ui/icons/src/index.ts @@ -0,0 +1 @@ +export * from './lib/Icons'; diff --git a/libs/ui/icons/src/lib/Icons.tsx b/libs/ui/icons/src/lib/Icons.tsx new file mode 100644 index 00000000..1e19ab85 --- /dev/null +++ b/libs/ui/icons/src/lib/Icons.tsx @@ -0,0 +1,135 @@ +import { + Check, + ChevronDown, + ChevronRight, + ChevronsUpDown, + Plus, + LogOut, + Squircle, + Trash, + Circle, + CircleDashed, + CircleDotDashed, + TriangleAlert, + Minus, + Copy, + RotateCw, + SquareCheckBig, + Terminal, + Lock, + FileKey, + Globe, + FileClock, + ExternalLink, + Wallet, + X, + Box, + SquareFunction, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { tv } from 'tailwind-variants'; +import { RestateEnvironment } from './custom-icons/RestateEnvironment'; +import { Restate } from './custom-icons/Restate'; +import { CircleX } from './custom-icons/CircleX'; +import { Lambda } from './custom-icons/Lambda'; +import { Docs } from './custom-icons/Docs'; +import { Github } from './custom-icons/Github'; +import { Discord } from './custom-icons/Discord'; +import { SupportTicket } from './custom-icons/SupportTicket'; +import { Help } from './custom-icons/Help'; + +export const enum IconName { + ChevronDown = 'ChevronDown', + ChevronRight = 'ChevronRight', + Check = 'Check', + RestateEnvironment = 'RestateEnvironment', + Restate = 'Restate', + ChevronsUpDown = 'ChevronsUpDown', + Plus = 'Plus', + LogOut = 'LogOut', + Squircle = 'Squircle', + Trash = 'Trash', + Circle = 'Circle', + CircleDashed = 'CircleDashed', + TriangleAlert = 'TriangleAlert', + CircleDotDashed = 'CircleDotDashed', + CircleX = 'CircleX', + Minus = 'Minus', + Copy = 'Copy', + Retry = 'Retry', + SquareCheckBig = 'SquareCheckBig', + Http = 'Http', + Security = 'Security', + ApiKey = 'ApiKey', + Cli = 'Cli', + Log = 'Log', + ExternalLink = 'ExternalLink', + Wallet = 'Wallet', + X = 'X', + Docs = 'Docs', + Discord = 'Discord', + Github = 'Github', + SupportTicket = 'SupportTicket', + Help = 'Help', + Lambda = 'Lambda', + Box = 'Box', + Function = 'SquareFunction', +} +export interface IconsProps { + name: IconName; + ['aria-hidden']?: boolean; + className?: string; +} + +const ICONS: Record = { + [IconName.ChevronDown]: ChevronDown, + [IconName.Check]: Check, + [IconName.ChevronRight]: ChevronRight, + [IconName.ChevronsUpDown]: ChevronsUpDown, + [IconName.Plus]: Plus, + [IconName.LogOut]: LogOut, + [IconName.RestateEnvironment]: RestateEnvironment, + [IconName.Squircle]: Squircle, + [IconName.Trash]: Trash, + [IconName.Circle]: Circle, + [IconName.CircleDashed]: CircleDashed, + [IconName.TriangleAlert]: TriangleAlert, + [IconName.CircleDotDashed]: CircleDotDashed, + [IconName.CircleX]: CircleX, + [IconName.Minus]: Minus, + [IconName.Copy]: Copy, + [IconName.SquareCheckBig]: SquareCheckBig, + [IconName.Retry]: RotateCw, + [IconName.Security]: Lock, + [IconName.Cli]: Terminal, + [IconName.ApiKey]: FileKey, + [IconName.Http]: Globe, + [IconName.Log]: FileClock, + [IconName.ExternalLink]: ExternalLink, + [IconName.Wallet]: Wallet, + [IconName.X]: X, + [IconName.Restate]: Restate, + [IconName.Docs]: Docs, + [IconName.Github]: Github, + [IconName.Discord]: Discord, + [IconName.SupportTicket]: SupportTicket, + [IconName.Help]: Help, + [IconName.Lambda]: Lambda, + [IconName.Box]: Box, + [IconName.Function]: SquareFunction, +}; + +const styles = tv({ + base: 'w-[1.5em] h-[1.5em] text-current', +}); + +export function Icon({ name, className, ...props }: IconsProps) { + const IconComponent = ICONS[name]; + return ( +