diff --git a/dashboard/web/.pnpmfile.cjs b/dashboard/web/.pnpmfile.cjs new file mode 100644 index 0000000..b87ea87 --- /dev/null +++ b/dashboard/web/.pnpmfile.cjs @@ -0,0 +1,99 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const PACKAGE_SCOPE = '@bytelyst/'; +const PACKAGE_SOURCE = process.env.BYTELYST_PACKAGE_SOURCE || 'common-plat'; +const COMMON_PLAT_ROOT = process.env.BYTELYST_COMMON_PLAT_ROOT || path.resolve(__dirname, '../../../learning_ai_common_plat'); +const COMMON_PLAT_PACKAGES_ROOT = path.join(COMMON_PLAT_ROOT, 'packages'); +const VERSION_CACHE = new Map(); +let loggedSource = false; + +function packageDirFor(name) { + return name.startsWith(PACKAGE_SCOPE) ? name.slice(PACKAGE_SCOPE.length) : null; +} + +function pathIfPackageExists(rootDir, name) { + const packageDir = packageDirFor(name); + if (!packageDir) return null; + + const candidate = path.join(rootDir, packageDir); + return fs.existsSync(path.join(candidate, 'package.json')) ? candidate : null; +} + +function readPackageVersion(packagePath) { + if (VERSION_CACHE.has(packagePath)) { + return VERSION_CACHE.get(packagePath); + } + + try { + const packageJson = JSON.parse(fs.readFileSync(path.join(packagePath, 'package.json'), 'utf8')); + const version = packageJson.version || null; + VERSION_CACHE.set(packagePath, version); + return version; + } catch { + VERSION_CACHE.set(packagePath, null); + return null; + } +} + +function resolveSpecifier(name) { + if (!name.startsWith(PACKAGE_SCOPE)) { + return null; + } + + const commonPlatPath = pathIfPackageExists(COMMON_PLAT_PACKAGES_ROOT, name); + + if (PACKAGE_SOURCE === 'common-plat') { + // For non-workspace packages, use file: protocol + if (commonPlatPath) { + return `file:${commonPlatPath}`; + } + return null; + } + + if (PACKAGE_SOURCE === 'gitea') { + return commonPlatPath ? readPackageVersion(commonPlatPath) : null; + } + + // Default to file: protocol for local packages + if (commonPlatPath) { + return `file:${commonPlatPath}`; + } + return null; +} + +function rewriteDependencySet(dependencies = {}) { + for (const dependencyName of Object.keys(dependencies)) { + const rewrittenSpecifier = resolveSpecifier(dependencyName); + if (rewrittenSpecifier) { + dependencies[dependencyName] = rewrittenSpecifier; + } + } +} + +function logSourceOnce() { + if (loggedSource) { + return; + } + + loggedSource = true; + process.stderr.write( + `[bytelyst] pnpm package source=${PACKAGE_SOURCE} commonPlatRoot=${COMMON_PLAT_ROOT}\n`, + ); +} + +module.exports = { + hooks: { + readPackage(packageJson) { + logSourceOnce(); + if (packageJson.name?.startsWith(PACKAGE_SCOPE)) { + return packageJson; + } + + rewriteDependencySet(packageJson.dependencies); + rewriteDependencySet(packageJson.devDependencies); + rewriteDependencySet(packageJson.optionalDependencies); + return packageJson; + }, + }, +}; diff --git a/dashboard/web/package.json b/dashboard/web/package.json new file mode 100644 index 0000000..356e366 --- /dev/null +++ b/dashboard/web/package.json @@ -0,0 +1,44 @@ +{ + "name": "@bytelyst/devops-web", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.6.5", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" + }, + "dependencies": { + "@bytelyst/design-tokens": "file:../../../learning_ai_common_plat/packages/design-tokens", + "@bytelyst/ui": "file:../../../learning_ai_common_plat/packages/ui", + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", + "next": "16.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@tailwindcss/postcss": "^4.0.0", + "@testing-library/jest-dom": "^6.6.5", + "@testing-library/react": "^16.2.0", + "@types/node": "^25.0.3", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "jsdom": "^26.0.3", + "playwright": "^1.58.2", + "postcss": "^8.4.49", + "tailwindcss": "^4.0.0", + "typescript": "^5.9.3", + "vitest": "^3.1.2" + } +} diff --git a/dashboard/web/src/app/globals.css b/dashboard/web/src/app/globals.css new file mode 100644 index 0000000..96dd454 --- /dev/null +++ b/dashboard/web/src/app/globals.css @@ -0,0 +1,5 @@ +@import "@bytelyst/design-tokens/css"; + +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/dashboard/web/src/components/ui/Primitives.tsx b/dashboard/web/src/components/ui/Primitives.tsx new file mode 100644 index 0000000..7deb1cd --- /dev/null +++ b/dashboard/web/src/components/ui/Primitives.tsx @@ -0,0 +1,226 @@ +import * as React from 'react'; +import { + Badge as CommonBadge, + Button as CommonButton, + Input as CommonInput, + Select as CommonSelect, + Textarea as CommonTextarea, + type BadgeProps as CommonBadgeProps, + type ButtonProps as CommonButtonProps, + type InputProps as CommonInputProps, + type SelectProps as CommonSelectProps, + type TextareaProps as CommonTextareaProps, +} from '@bytelyst/ui'; +import { cn } from '@/lib/utils'; + +// Re-export all shared primitives from @bytelyst/ui +export { + ActionMenu, + AlertBanner, + DataList, + DataTable, + Drawer, + EmptyState, + EntityCard, + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldTitle, + FilterBar, + FormSection, + MetricCard, + Modal, + PageHeader, + Panel, + PanelBody, + PanelDescription, + PanelHeader, + PanelTitle, + Skeleton, + Timeline, + Toolbar, + // Add other @bytelyst/ui components as needed +} from '@bytelyst/ui'; + +// Define product-specific variants +type ProductButtonVariant = NonNullable | 'link'; +type ProductButtonSize = NonNullable | 'icon'; +type ProductFieldVariant = 'surface' | 'muted'; +type ProductFieldSize = 'sm' | 'md'; +type ProductBadgeVariant = NonNullable | 'danger'; +type ProductStatusTone = 'success' | 'warning' | 'error' | 'info' | 'neutral'; + +// Extend interfaces with product-specific props +export interface ButtonProps extends Omit { + variant?: ProductButtonVariant; + size?: ProductButtonSize; +} + +export interface IconButtonProps extends Omit { + icon: React.ReactNode; + label: string; +} + +export interface InputProps extends CommonInputProps { + controlSize?: ProductFieldSize; + variant?: ProductFieldVariant; +} + +export interface SelectProps extends CommonSelectProps { + controlSize?: ProductFieldSize; + variant?: ProductFieldVariant; +} + +export interface TextareaProps extends CommonTextareaProps { + controlSize?: ProductFieldSize; + variant?: ProductFieldVariant; +} + +export interface BadgeProps extends Omit { + variant?: ProductBadgeVariant; +} + +// Product status mapping for badges (devops-specific statuses) +export type ProductStatus = + | 'active' | 'approved' | 'blocked' | 'cancelled' + | 'connected' | 'danger' | 'degraded' | 'disabled' | 'error' + | 'failed' | 'idle' | 'info' | 'live' | 'neutral' | 'off' + | 'ok' | 'paper' | 'pending' | 'rejected' | 'success' + | 'synced' | 'warning' | 'healthy' | 'unhealthy' | 'maintenance'; + +const productStatusTone: Record = { + active: 'success', + approved: 'success', + blocked: 'error', + cancelled: 'neutral', + connected: 'success', + danger: 'error', + degraded: 'warning', + disabled: 'neutral', + error: 'error', + failed: 'error', + healthy: 'success', + idle: 'neutral', + info: 'info', + live: 'warning', + maintenance: 'warning', + neutral: 'neutral', + off: 'neutral', + ok: 'success', + paper: 'info', + pending: 'warning', + rejected: 'error', + success: 'success', + synced: 'success', + unhealthy: 'error', + warning: 'warning', +}; + +// Helper function to map product status to tone +export function statusToneFor(status: ProductStatus | string | null | undefined): ProductStatusTone { + if (!status) return 'neutral'; + const normalized = status.trim().toLowerCase().replace(/[\s_]+/g, '-') as ProductStatus; + return productStatusTone[normalized] ?? 'neutral'; +} + +// Product-specific component implementations +export const Button = React.forwardRef( + ({ variant = 'primary', size = 'md', className, ...props }, ref) => ( + + ), +); + +Button.displayName = 'Button'; + +export const IconButton = React.forwardRef( + ({ icon, label, variant = 'ghost', size = 'icon', className, ...props }, ref) => ( + + ), +); + +IconButton.displayName = 'IconButton'; + +export const Input = React.forwardRef( + ({ controlSize = 'md', variant = 'surface', className, ...props }, ref) => ( + + ), +); + +Input.displayName = 'Input'; + +export const Select = React.forwardRef( + ({ controlSize = 'md', variant = 'surface', className, ...props }, ref) => ( + + ), +); + +Select.displayName = 'Select'; + +export const Textarea = React.forwardRef( + ({ controlSize = 'md', variant = 'surface', className, ...props }, ref) => ( + + ), +); + +Textarea.displayName = 'Textarea'; + +export function Badge({ variant = 'neutral', ...props }: BadgeProps) { + return ; +} + +export function ProductStatusBadge({ + status, + children, +}: { + status: ProductStatus | string | null | undefined; + children?: React.ReactNode; +}) { + return ( + + {children ?? status ?? 'Unknown'} + + ); +} diff --git a/dashboard/web/src/lib/utils.ts b/dashboard/web/src/lib/utils.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/dashboard/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +}