feat: Add Markdown View Plugin with React and Chakra UI

- Implemented main application structure with App and View components.
- Integrated React Markdown for rendering Markdown content.
- Added API function to fetch Markdown content from the backend.
- Created a custom Chakra UI theme to align with Airflow's design.
- Configured Vite for building the application with appropriate asset paths.
- Included a sample Markdown file for initial content display.
- Set up TypeScript configuration for better development experience.
This commit is contained in:
2025-05-10 06:50:25 +00:00
parent 3593103630
commit e6d03a2c2e
17 changed files with 4282 additions and 0 deletions

1
.gitignore vendored
View File

@@ -299,4 +299,5 @@ dist
.yarn/build-state.yml .yarn/build-state.yml
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
.pnpm-store/

View File

@@ -0,0 +1,76 @@
## Building the Plugin
You can build the plugin into a wheel file using any PEP-517 compatible build frontend. The build process will automatically:
1. Install UI dependencies using pnpm
2. Build the React frontend
3. Package everything into a wheel file
This is handled by a custom Hatch build hook (`hatch_build.py`).
### Example build commands
```bash
# Using uv (recommended)
uv build
# Or using pip
pip wheel .
# Or using hatch directly
hatch build
```
The build process will fail if `pnpm` is not installed or if the UI build fails.
## Quick Test Checklist
After building, you can quickly verify that the UI static files are included in the wheel:
```bash
# one-liner smoke test
rm -rf ui/dist && uv build
unzip -l dist/airflow_markdown_view_plugin-*.whl | grep 'ui/dist/index.*.js'
```
If the grep shows your JS bundles, youre golden. If not, the hook didnt run—fix it before shipping.
# Markdown View Airflow Plugin
This plugin allows users to render Markdown content from a `view.md` file directly within the Airflow UI.
## Features
* Displays content from a `markdown_view_plugin/view.md` file.
* Uses FastAPI for the backend and React/Vite for the frontend.
* Integrates with Airflow's UI theme, including light/dark mode support.
* Provides a theme toggle button within the plugin's view.
## Setup
1. **Place the Plugin:** Put the entire `markdown_view_plugin` directory into your Airflow `plugins` folder.
2. **Install UI Dependencies:** Navigate to the `markdown_view_plugin/ui` directory and run:
```bash
pnpm install
```
3. **Build the UI:** After installing dependencies, build the React frontend:
```bash
pnpm run build
```
This will create a `dist` folder inside `markdown_view_plugin/ui`.
4. **Restart Airflow Webserver:** After adding the plugin and building the UI, restart your Airflow webserver and scheduler.
5. **Access the Plugin:** In the Airflow UI, navigate to the "Plugins" menu. You should find a link to the "Markdown View" (the exact name and category might depend on future menu link configuration in `markdown_view_plugin.py`). Clicking this link will take you to the plugin's page.
## Customization
* To change the displayed content, edit the `markdown_view_plugin/view.md` file.
* The UI can be further customized by modifying the React components in `markdown_view_plugin/ui/src/`.
* The backend logic is in `markdown_view_plugin/markdown_view_plugin.py`.
## Development
* **Backend:** The FastAPI application is defined in `markdown_view_plugin.py`.
* **Frontend:** The React/Vite application is in the `markdown_view_plugin/ui/` directory.
* To run the frontend in development mode (with hot reloading), navigate to `markdown_view_plugin/ui` and run `pnpm run dev`.
* Ensure your `vite.config.ts` has the correct `base` path and any necessary proxy configurations if you are developing against a running Airflow instance.

View File

@@ -0,0 +1,24 @@
# hatch_build.py
import subprocess
import pathlib
import shutil
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class MarkdownBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
# 1. Compile the UI exactly once per build
ui_dir = pathlib.Path(__file__).parent / "ui"
dist_dir = ui_dir / "dist"
# Clean any existing dist directory to ensure fresh build
if dist_dir.exists():
shutil.rmtree(dist_dir)
# Install dependencies and build the UI
subprocess.run([
"pnpm", "install", "--frozen-lockfile"
], cwd=ui_dir, check=True)
subprocess.run(["pnpm", "run", "build"], cwd=ui_dir, check=True)
# 2. Force-include the compiled assets in the wheel
build_data["force_include"][str(dist_dir)] = "ui/dist"

View File

@@ -0,0 +1,85 @@
# Python backend for the Markdown View Plugin using FastAPI
import anyio # For asynchronous file operations
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from airflow.plugins_manager import AirflowPlugin
import importlib.resources as resources
# Use importlib.resources to locate static files and view.md
static_files_dir_ref = resources.files("markdown_view_plugin") / "ui" / "dist"
view_md_path_ref = resources.files("markdown_view_plugin") / "view.md"
# Create a FastAPI app
markdown_fastapi_app = FastAPI(
title="Markdown View Plugin",
description="A plugin to render Markdown content in Airflow UI.",
version="1.0.0",
)
# Mount static files for the React UI
# The path "/static/markdown_view_plugin" must match the 'base' in ui/vite.config.ts
with resources.as_file(static_files_dir_ref) as static_files_dir:
markdown_fastapi_app.mount(
"/static/markdown_view_plugin",
StaticFiles(directory=str(static_files_dir)),
name="markdown_view_plugin_static",
)
# API endpoint to get markdown content
@markdown_fastapi_app.get("/markdown_view/api/view")
async def get_markdown_content_api():
"""Asynchronously read and return the content of view.md."""
with resources.as_file(view_md_path_ref) as view_md_path:
try:
async with await anyio.open_file(view_md_path) as f:
content = await f.read()
return {"markdown": content}
except FileNotFoundError:
raise HTTPException(status_code=404, detail="view.md not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Endpoint to serve the main index.html of the React app
@markdown_fastapi_app.get("/markdown_view")
async def serve_markdown_ui():
"""Serve the main index.html for the plugin's UI."""
with resources.as_file(static_files_dir_ref / "index.html") as index_html_path:
if not index_html_path.exists():
raise HTTPException(
status_code=404,
detail="index.html not found. Did you build the UI? (cd ui && pnpm run build)",
)
return FileResponse(str(index_html_path))
# Define the Airflow plugin
class MarkdownViewPlugin(AirflowPlugin):
"""
Airflow Plugin for Markdown View.
This plugin provides a FastAPI backend and a React frontend
to display Markdown content within the Airflow UI.
"""
name = "markdown_view_plugin"
fastapi_apps = [
{
"app": markdown_fastapi_app,
"name": "markdown_view_app", # A unique name for this FastAPI app
"app_mount": "/markdown_view_plugin_mount",
}
]
# Example menu link (optional, can also be configured via Airflow UI settings)
menu_links = [
{
"name": "Markdown Viewer",
"href": "/markdown_view_plugin_mount/markdown_view", # This should match the app_mount + @markdown_fastapi_app.get path for the UI
"category": "Plugins",
}
]
# For Airflow to pick up the plugin, the class name must match the filename (snake_case to PascalCase)
# or be explicitly defined in an __init__.py in the plugin\'s root directory.
# Assuming filename is markdown_view_plugin.py, class MarkdownViewPlugin is correct.

View File

@@ -0,0 +1,105 @@
[project]
name = "airflow-markdown-view-plugin"
version = "0.1.0"
description = "Airflow UI plugin to render Markdown content using FastAPI and React."
readme = "README.md"
requires-python = ">=3.9"
authors = [
{name = "Abhishek Bhakat", email = "abhishek.bhakat@hotmail.com"}
]
dependencies = [
"apache-airflow>=3.0.0", # Adjust as per your Airflow version compatibility
"fastapi>=0.100.0", # Specify a version range as needed
"anyio>=3.0.0", # Specify a version range as needed
"importlib-resources>=5.0.0" # For static file access in Python <3.9, stdlib in >=3.9
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Plugins",
"Framework :: Apache Airflow",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: System :: Monitoring"
]
license = {text = "MIT"} # Updated to new license expression
# license-files = ["LICEN[CS]E*"] # Optional: if you add a LICENSE file
# Hatch-specific way to specify top-level Python modules
py-modules = ["markdown_view_plugin"]
[project.urls]
GitHub = "https://github.com/abhishekbhakat/airflow-markdown-view-plugin" # Replace with actual URL if it exists
Issues = "https://github.com/abhishekbhakat/airflow-markdown-view-plugin/issues" # Replace with actual URL
[project.entry-points."airflow.plugins"]
markdown_view_plugin = "markdown_view_plugin:MarkdownViewPlugin"
[project.optional-dependencies]
dev = [
"build>=1.2.2",
"pre-commit>=4.0.1",
"ruff>=0.9.2"
]
[build-system]
requires = ["hatchling>=1.24"]
build-backend = "hatchling.build"
[tool.hatch.build.hooks.custom]
path = "hatch_build.py"
[tool.hatch.build.targets.wheel]
# Ensure data files are included in the wheel at the root.
# markdown_view_plugin.py will be handled by `project.py-modules`.
# These paths are relative to the pyproject.toml file.
include = [
"view.md",
"README.md",
"ui/dist" # This tells Hatch to include the ui/dist directory and its contents
]
[tool.ruff]
line-length = 200
indent-width = 4
fix = true
preview = true
lint.select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
"W", # pycodestyle warnings
"C90", # Complexity
"C", # flake8-comprehensions
"ISC", # flake8-implicit-str-concat
"T10", # flake8-debugger
"A", # flake8-builtins
"UP", # pyupgrade
]
lint.ignore = [
"C416", # Unnecessary list comprehension - rewrite as a generator expression
"C408", # Unnecessary `dict` call - rewrite as a literal
"ISC001", # Single line implicit string concatenation
"C901"
]
lint.fixable = ["ALL"]
lint.unfixable = []
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
[tool.ruff.lint.isort]
combine-as-imports = true
[tool.ruff.lint.mccabe]
max-complexity = 12

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" style="height: 100%;">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown View</title>
</head>
<body style="height: 100%; margin: 0;">
<div id="root" style="height: 100%;"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
{
"name": "markdown-view-plugin-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/icons": "^2.2.4",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"framer-motion": "^11.0.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.1.0"
}
}

3639
markdown_view_plugin/ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
import View from './View';
function App() {
return (
<View />
);
}
export default App;

View File

@@ -0,0 +1,120 @@
import React, { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { fetchMarkdownContent } from './api';
import {
Box,
Heading,
Text,
Code,
Link,
ListItem,
OrderedList,
UnorderedList,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Divider,
Container, // Added for layout
Flex, // Added for layout
IconButton,// Added for theme toggle
useColorMode // Hook for theme toggle
} from '@chakra-ui/react';
import { SunIcon, MoonIcon } from '@chakra-ui/icons'; // Icons for theme toggle
// Attempt to mimic Airflow's Chakra UI components styling
const chakraComponents = {
h1: (props: any) => <Heading as="h1" size="xl" my={4} {...props} />,
h2: (props: any) => <Heading as="h2" size="lg" my={3} {...props} />,
h3: (props: any) => <Heading as="h3" size="md" my={2} {...props} />,
p: (props: any) => <Text my={2} {...props} />,
code: (props: any) => {
const { inline, className, children, ...rest } = props;
return !inline ? (
<Box
as="pre"
p={4}
rounded="md"
bg="chakra-body-bg"
boxShadow="sm"
overflowX="auto"
my={2}
>
<Code className={className} bg="transparent" {...rest}>
{children}
</Code>
</Box>
) : (
<Code colorScheme="purple" fontSize="0.9em" {...rest}>{children}</Code>
);
},
a: (props: any) => <Link color="teal.500" isExternal {...props} />,
ul: (props: any) => <UnorderedList stylePosition="inside" my={2} {...props} />,
ol: (props: any) => <OrderedList stylePosition="inside" my={2} {...props} />,
li: (props: any) => <ListItem {...props} />,
table: (props: any) => <Table variant="simple" my={4} {...props} />,
thead: (props: any) => <Thead {...props} />,
tbody: (props: any) => <Tbody {...props} />,
tr: (props: any) => <Tr {...props} />,
th: (props: any) => <Th {...props} />,
td: (props: any) => <Td {...props} />,
hr: (props: any) => <Divider my={4} {...props} />,
};
const View: React.FC = () => {
const [markdown, setMarkdown] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const { colorMode, toggleColorMode } = useColorMode(); // Hook for theme toggle
useEffect(() => {
const loadContent = async () => {
try {
const content = await fetchMarkdownContent();
setMarkdown(content);
} catch (err) {
setError((err as Error).message);
}
};
loadContent();
}, []);
if (error) {
return (
<Container maxW="container.xl" py={10}>
<Box color="red.500">Error loading content: {error}</Box>
</Container>
);
}
if (!markdown && !error) { // Show loading only if no error
return (
<Container maxW="container.xl" py={10}>
<Box>Loading content...</Box>
</Container>
);
}
return (
<Container maxW="container.xl" py={6} height="100%" display="flex" flexDirection="column">
<Flex justifyContent="space-between" alignItems="center" mb={6} flexShrink={0}>
<Heading as="h1" size="lg">Markdown View</Heading>
<IconButton
aria-label="Toggle theme"
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
/>
</Flex>
<Box p={5} borderWidth="1px" borderRadius="lg" boxShadow="md" overflowY="auto" flexGrow={1}>
<ReactMarkdown components={chakraComponents} remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</Box>
</Container>
);
};
export default View;

View File

@@ -0,0 +1,19 @@
// API functions to interact with the Flask backend
// Adjusted API_BASE_URL to include the Airflow app_mount point
const API_BASE_URL = '/markdown_view_plugin_mount/markdown_view';
export async function fetchMarkdownContent(): Promise<string> {
try {
const response = await fetch(`${API_BASE_URL}/api/view`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data.markdown;
} catch (error) {
console.error("Failed to fetch markdown content:", error);
throw error;
}
}

View File

@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App'; // Removed .tsx extension
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
import theme from './theme'; // Removed .ts extension
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ChakraProvider theme={theme}> {/* Apply the custom theme */}
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<App />
</ChakraProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,71 @@
import { extendTheme, type ThemeConfig } from '@chakra-ui/react';
// Basic color mode config
const config: ThemeConfig = {
initialColorMode: 'system',
useSystemColorMode: true,
};
// Define some basic colors similar to Airflow's scheme
// This is a simplified version. For full consistency, you'd replicate more from Airflow's core theme.ts
const colors = {
airflow: {
50: '#EBF8FF', // Lightest blue
100: '#BEE3F8',
200: '#90CDF4',
300: '#63B3ED',
400: '#4299E1', // Primary blue
500: '#3182CE',
600: '#2B6CB0',
700: '#2C5282',
800: '#2A4365', // Darkest blue
900: '#1A365D',
},
success: {
500: '#38A169', // Green
},
error: {
500: '#E53E3E', // Red
},
};
const theme = extendTheme({
config,
colors,
styles: {
global: (props: any) => ({
body: {
bg: props.colorMode === 'dark' ? 'gray.800' : 'gray.50',
color: props.colorMode === 'dark' ? 'whiteAlpha.900' : 'gray.800',
height: '100%', // Ensure body takes full height
margin: 0,
},
html: {
height: '100%',
},
'#root': { // Ensure root div also takes full height
height: '100%',
}
}),
},
components: {
Button: {
baseStyle: {
// Example: fontWeight: 'bold',
},
variants: {
solid: (props: any) => ({
bg: props.colorMode === 'dark' ? 'airflow.300' : 'airflow.500',
color: props.colorMode === 'dark' ? 'gray.800' : 'white',
_hover: {
bg: props.colorMode === 'dark' ? 'airflow.400' : 'airflow.600',
}
}),
},
},
// You can add more component-specific style overrides here if needed
// For example, for Markdown components if View.tsx styling needs to be theme-aware
},
});
export default theme;

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: '/markdown_view_plugin_mount/static/markdown_view_plugin/', // Important for Airflow to find assets
build: {
outDir: 'dist',
rollupOptions: {
output: {
entryFileNames: 'assets/[name].js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name].[ext]',
},
},
},
})

View File

@@ -0,0 +1,18 @@
# Welcome to Markdown View Plugin!
This is a sample `view.md` file.
You can write **Markdown** here, and it will be rendered in the Airflow UI.
## Features
* Supports standard Markdown syntax.
* Styled with Airflow's theme (hopefully!).
### Example List
1. First item
2. Second item
3. Third item
---
Edit this file to change the content of your view.