jscheffl commented on code in PR #55301: URL: https://github.com/apache/airflow/pull/55301#discussion_r2325987777
########## providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py: ########## @@ -100,3 +105,57 @@ def jobs( jobs=result, total_entries=len(result), ) + + +class MaintenanceRequest(BaseModel): + """Request body for maintenance operations.""" + + maintenance_comment: Annotated[str, Field(description="Comment describing the maintenance reason.")] + Review Comment: For consistency, can you move this class to providers/edge3/src/airflow/providers/edge3/worker_api/datamodels_ui.py please? ########## providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py: ########## @@ -100,3 +105,57 @@ def jobs( jobs=result, total_entries=len(result), ) + + +class MaintenanceRequest(BaseModel): + """Request body for maintenance operations.""" + + maintenance_comment: Annotated[str, Field(description="Comment describing the maintenance reason.")] + + +@ui_router.post( + "/worker/{worker_name}/maintenance", +) +def request_worker_maintenance( + worker_name: str, + maintenance_request: MaintenanceRequest, + session: SessionDep, +) -> None: + """Put a worker into maintenance mode.""" + # Check if worker exists first + worker_query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name) + worker = session.scalar(worker_query) + if not worker: + raise HTTPException(status_code=404, detail=f"Worker {worker_name} not found") + + # Format the comment with timestamp and username (username will be added by plugin layer) + formatted_comment = f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] - UI user put node into maintenance mode\nComment: {maintenance_request.maintenance_comment}" + + try: + request_maintenance(worker_name, formatted_comment, session=session) + session.commit() # Explicitly commit the transaction + except Exception as e: + session.rollback() # Rollback on error + raise HTTPException(status_code=400, detail=str(e)) + + +@ui_router.delete( + "/worker/{worker_name}/maintenance", +) Review Comment: Same as above ```suggestion dependencies=[ Depends(requires_access_view(access_view=AccessView.JOBS)), ], ) ``` ########## providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx: ########## @@ -16,22 +16,178 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Table } from "@chakra-ui/react"; +import { Box, Button, Table, Textarea, VStack, HStack } from "@chakra-ui/react"; import { useUiServiceWorker } from "openapi/queries"; +import type { Worker } from "openapi/requests/types.gen"; +import { useState } from "react"; import { ErrorAlert } from "src/components/ErrorAlert"; import { WorkerStateBadge } from "src/components/WorkerStateBadge"; import { autoRefreshInterval } from "src/utils"; +interface MaintenanceFormProps { + onSubmit: (comment: string) => void; + onCancel: () => void; +} + +const MaintenanceForm = ({ onCancel, onSubmit }: MaintenanceFormProps) => { + const [comment, setComment] = useState(""); + + const handleSubmit = () => { + if (comment.trim()) { + onSubmit(comment.trim()); + } + }; + + return ( + <VStack gap={2} align="stretch"> Review Comment: As with react we have much more cool widgets and options compared to legacy 2.x UI, how about making this a modal dialog? Can also be extracted as a component into a separate tsx file to be used as `<MaintenanceForm worker={ worker } />` ########## providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py: ########## @@ -100,3 +105,57 @@ def jobs( jobs=result, total_entries=len(result), ) + + +class MaintenanceRequest(BaseModel): + """Request body for maintenance operations.""" + + maintenance_comment: Annotated[str, Field(description="Comment describing the maintenance reason.")] + + +@ui_router.post( + "/worker/{worker_name}/maintenance", +) +def request_worker_maintenance( + worker_name: str, + maintenance_request: MaintenanceRequest, + session: SessionDep, +) -> None: + """Put a worker into maintenance mode.""" + # Check if worker exists first + worker_query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name) + worker = session.scalar(worker_query) + if not worker: + raise HTTPException(status_code=404, detail=f"Worker {worker_name} not found") + + # Format the comment with timestamp and username (username will be added by plugin layer) + formatted_comment = f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] - UI user put node into maintenance mode\nComment: {maintenance_request.maintenance_comment}" Review Comment: Can you fetch the user name from the `depends` (or add a user depends as FastAPI decorator) and make this into the formatted string such that it is directly visible who made it to maintenance? ########## providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx: ########## @@ -16,22 +16,178 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Table } from "@chakra-ui/react"; +import { Box, Button, Table, Textarea, VStack, HStack } from "@chakra-ui/react"; import { useUiServiceWorker } from "openapi/queries"; +import type { Worker } from "openapi/requests/types.gen"; +import { useState } from "react"; import { ErrorAlert } from "src/components/ErrorAlert"; import { WorkerStateBadge } from "src/components/WorkerStateBadge"; import { autoRefreshInterval } from "src/utils"; +interface MaintenanceFormProps { + onSubmit: (comment: string) => void; + onCancel: () => void; +} + +const MaintenanceForm = ({ onCancel, onSubmit }: MaintenanceFormProps) => { + const [comment, setComment] = useState(""); + + const handleSubmit = () => { + if (comment.trim()) { + onSubmit(comment.trim()); + } + }; + + return ( + <VStack gap={2} align="stretch"> + <Textarea + placeholder="Enter maintenance comment (required)" + value={comment} + onChange={(e) => setComment(e.target.value)} + required + maxLength={1024} + size="sm" + /> + <HStack gap={2}> + <Button size="sm" colorScheme="blue" onClick={handleSubmit} disabled={!comment.trim()}> + Confirm Maintenance + </Button> + <Button size="sm" variant="outline" onClick={onCancel}> + Cancel + </Button> + </HStack> + </VStack> + ); +}; + export const WorkerPage = () => { - const { data, error } = useUiServiceWorker(undefined, { + const { data, error, refetch } = useUiServiceWorker(undefined, { enabled: true, refetchInterval: autoRefreshInterval, }); + const [activeMaintenanceForm, setActiveMaintenanceForm] = useState<string | null>(null); + + const requestMaintenance = async (workerName: string, comment: string) => { + try { + console.log(`Requesting maintenance for worker: ${workerName}, comment: ${comment}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = { + "Content-Type": "application/json", + }; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + body: JSON.stringify({ maintenance_comment: comment }), + credentials: "same-origin", + headers, + method: "POST", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Maintenance request failed:", response.status, errorText); + throw new Error(`Failed to request maintenance: ${response.status} ${errorText}`); + } + + console.log("Maintenance request successful"); + setActiveMaintenanceForm(null); + refetch(); + } catch (error) { + console.error("Error requesting maintenance:", error); + alert(`Error requesting maintenance: ${error}`); + } + }; + + const exitMaintenance = async (workerName: string) => { + try { + console.log(`Exiting maintenance for worker: ${workerName}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = {}; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + credentials: "same-origin", + headers, + method: "DELETE", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Exit maintenance failed:", response.status, errorText); + throw new Error(`Failed to exit maintenance: ${response.status} ${errorText}`); + } + + console.log("Exit maintenance successful"); + refetch(); + } catch (error) { + console.error("Error exiting maintenance:", error); + alert(`Error exiting maintenance: ${error}`); + } + }; + + const renderOperationsCell = (worker: Worker) => { + const workerName = worker.worker_name; + const state = worker.state; + + if (state === "idle" || state === "running") { + if (activeMaintenanceForm === workerName) { + return ( + <MaintenanceForm + onSubmit={(comment) => requestMaintenance(workerName, comment)} + onCancel={() => setActiveMaintenanceForm(null)} + /> + ); + } + return ( + <Button size="sm" colorScheme="blue" onClick={() => setActiveMaintenanceForm(workerName)}> + Enter Maintenance Review Comment: To exit maintenance e.g. the icon HiOutlineArrowRightStartOnRectangle or vsc/VscDebugRestart could be suitable ########## providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py: ########## @@ -100,3 +105,57 @@ def jobs( jobs=result, total_entries=len(result), ) + + +class MaintenanceRequest(BaseModel): + """Request body for maintenance operations.""" + + maintenance_comment: Annotated[str, Field(description="Comment describing the maintenance reason.")] + + +@ui_router.post( + "/worker/{worker_name}/maintenance", +) Review Comment: To ensure this API is not "public/open" but authenticated, please add the access check ```suggestion dependencies=[ Depends(requires_access_view(access_view=AccessView.JOBS)), ], ) ``` ########## providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py: ########## @@ -100,3 +105,57 @@ def jobs( jobs=result, total_entries=len(result), ) + + +class MaintenanceRequest(BaseModel): + """Request body for maintenance operations.""" + + maintenance_comment: Annotated[str, Field(description="Comment describing the maintenance reason.")] + + +@ui_router.post( + "/worker/{worker_name}/maintenance", +) +def request_worker_maintenance( + worker_name: str, + maintenance_request: MaintenanceRequest, + session: SessionDep, +) -> None: + """Put a worker into maintenance mode.""" + # Check if worker exists first + worker_query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name) + worker = session.scalar(worker_query) + if not worker: + raise HTTPException(status_code=404, detail=f"Worker {worker_name} not found") Review Comment: Probably as follow-up... (no urgency to have it in this PR) the logic should be pushed into request_maintenance() method as it is redundant in CLI and other logic as well. ########## providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py: ########## @@ -100,3 +105,57 @@ def jobs( jobs=result, total_entries=len(result), ) + + +class MaintenanceRequest(BaseModel): + """Request body for maintenance operations.""" + + maintenance_comment: Annotated[str, Field(description="Comment describing the maintenance reason.")] + + +@ui_router.post( + "/worker/{worker_name}/maintenance", +) +def request_worker_maintenance( + worker_name: str, + maintenance_request: MaintenanceRequest, + session: SessionDep, +) -> None: + """Put a worker into maintenance mode.""" + # Check if worker exists first + worker_query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name) + worker = session.scalar(worker_query) + if not worker: + raise HTTPException(status_code=404, detail=f"Worker {worker_name} not found") + + # Format the comment with timestamp and username (username will be added by plugin layer) + formatted_comment = f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] - UI user put node into maintenance mode\nComment: {maintenance_request.maintenance_comment}" + + try: + request_maintenance(worker_name, formatted_comment, session=session) + session.commit() # Explicitly commit the transaction + except Exception as e: + session.rollback() # Rollback on error Review Comment: As far as I see it implemented in other areas no rollback is needed if an exception is raised. ORM will roll back automatically. ```suggestion ``` ########## providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py: ########## @@ -100,3 +105,57 @@ def jobs( jobs=result, total_entries=len(result), ) + + +class MaintenanceRequest(BaseModel): + """Request body for maintenance operations.""" + + maintenance_comment: Annotated[str, Field(description="Comment describing the maintenance reason.")] + + +@ui_router.post( + "/worker/{worker_name}/maintenance", +) +def request_worker_maintenance( + worker_name: str, + maintenance_request: MaintenanceRequest, + session: SessionDep, +) -> None: + """Put a worker into maintenance mode.""" + # Check if worker exists first + worker_query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name) + worker = session.scalar(worker_query) + if not worker: + raise HTTPException(status_code=404, detail=f"Worker {worker_name} not found") + + # Format the comment with timestamp and username (username will be added by plugin layer) + formatted_comment = f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] - UI user put node into maintenance mode\nComment: {maintenance_request.maintenance_comment}" + + try: + request_maintenance(worker_name, formatted_comment, session=session) + session.commit() # Explicitly commit the transaction Review Comment: commit is not needed, if no exception raised the ORM will commit() after return ```suggestion ``` ########## providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py: ########## @@ -100,3 +105,57 @@ def jobs( jobs=result, total_entries=len(result), ) + + +class MaintenanceRequest(BaseModel): + """Request body for maintenance operations.""" + + maintenance_comment: Annotated[str, Field(description="Comment describing the maintenance reason.")] + + +@ui_router.post( + "/worker/{worker_name}/maintenance", +) +def request_worker_maintenance( + worker_name: str, + maintenance_request: MaintenanceRequest, + session: SessionDep, +) -> None: + """Put a worker into maintenance mode.""" + # Check if worker exists first + worker_query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name) + worker = session.scalar(worker_query) + if not worker: + raise HTTPException(status_code=404, detail=f"Worker {worker_name} not found") + + # Format the comment with timestamp and username (username will be added by plugin layer) + formatted_comment = f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] - UI user put node into maintenance mode\nComment: {maintenance_request.maintenance_comment}" + + try: + request_maintenance(worker_name, formatted_comment, session=session) + session.commit() # Explicitly commit the transaction + except Exception as e: + session.rollback() # Rollback on error + raise HTTPException(status_code=400, detail=str(e)) + + +@ui_router.delete( + "/worker/{worker_name}/maintenance", +) +def exit_worker_maintenance( + worker_name: str, + session: SessionDep, +) -> None: + """Exit a worker from maintenance mode.""" + # Check if worker exists first + worker_query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name) + worker = session.scalar(worker_query) + if not worker: + raise HTTPException(status_code=404, detail=f"Worker {worker_name} not found") + + try: + exit_maintenance(worker_name, session=session) + session.commit() # Explicitly commit the transaction Review Comment: as above ```suggestion ``` ########## providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx: ########## @@ -16,22 +16,178 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Table } from "@chakra-ui/react"; +import { Box, Button, Table, Textarea, VStack, HStack } from "@chakra-ui/react"; import { useUiServiceWorker } from "openapi/queries"; +import type { Worker } from "openapi/requests/types.gen"; +import { useState } from "react"; import { ErrorAlert } from "src/components/ErrorAlert"; import { WorkerStateBadge } from "src/components/WorkerStateBadge"; import { autoRefreshInterval } from "src/utils"; +interface MaintenanceFormProps { + onSubmit: (comment: string) => void; + onCancel: () => void; +} + +const MaintenanceForm = ({ onCancel, onSubmit }: MaintenanceFormProps) => { + const [comment, setComment] = useState(""); + + const handleSubmit = () => { + if (comment.trim()) { + onSubmit(comment.trim()); + } + }; + + return ( + <VStack gap={2} align="stretch"> + <Textarea + placeholder="Enter maintenance comment (required)" + value={comment} + onChange={(e) => setComment(e.target.value)} + required + maxLength={1024} + size="sm" + /> + <HStack gap={2}> + <Button size="sm" colorScheme="blue" onClick={handleSubmit} disabled={!comment.trim()}> + Confirm Maintenance + </Button> + <Button size="sm" variant="outline" onClick={onCancel}> + Cancel + </Button> + </HStack> + </VStack> + ); +}; + export const WorkerPage = () => { - const { data, error } = useUiServiceWorker(undefined, { + const { data, error, refetch } = useUiServiceWorker(undefined, { enabled: true, refetchInterval: autoRefreshInterval, }); + const [activeMaintenanceForm, setActiveMaintenanceForm] = useState<string | null>(null); + + const requestMaintenance = async (workerName: string, comment: string) => { + try { + console.log(`Requesting maintenance for worker: ${workerName}, comment: ${comment}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = { + "Content-Type": "application/json", + }; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + body: JSON.stringify({ maintenance_comment: comment }), + credentials: "same-origin", + headers, + method: "POST", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Maintenance request failed:", response.status, errorText); + throw new Error(`Failed to request maintenance: ${response.status} ${errorText}`); + } + + console.log("Maintenance request successful"); + setActiveMaintenanceForm(null); + refetch(); + } catch (error) { + console.error("Error requesting maintenance:", error); + alert(`Error requesting maintenance: ${error}`); + } + }; + + const exitMaintenance = async (workerName: string) => { + try { + console.log(`Exiting maintenance for worker: ${workerName}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = {}; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + credentials: "same-origin", + headers, + method: "DELETE", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Exit maintenance failed:", response.status, errorText); + throw new Error(`Failed to exit maintenance: ${response.status} ${errorText}`); + } + + console.log("Exit maintenance successful"); + refetch(); + } catch (error) { + console.error("Error exiting maintenance:", error); + alert(`Error exiting maintenance: ${error}`); + } + }; Review Comment: Actually I would assume you can use and leverage the generated axios wrappers and there is no need to implement the request and authentication manually. See generated code in `services.gen.ts` and `queries.ts` -->`useUiServiceRequestWorkerMaintenance` ########## providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py: ########## @@ -100,3 +105,57 @@ def jobs( jobs=result, total_entries=len(result), ) + + +class MaintenanceRequest(BaseModel): + """Request body for maintenance operations.""" + + maintenance_comment: Annotated[str, Field(description="Comment describing the maintenance reason.")] + + +@ui_router.post( + "/worker/{worker_name}/maintenance", +) +def request_worker_maintenance( + worker_name: str, + maintenance_request: MaintenanceRequest, + session: SessionDep, +) -> None: + """Put a worker into maintenance mode.""" + # Check if worker exists first + worker_query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name) + worker = session.scalar(worker_query) + if not worker: + raise HTTPException(status_code=404, detail=f"Worker {worker_name} not found") + + # Format the comment with timestamp and username (username will be added by plugin layer) + formatted_comment = f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] - UI user put node into maintenance mode\nComment: {maintenance_request.maintenance_comment}" + + try: + request_maintenance(worker_name, formatted_comment, session=session) + session.commit() # Explicitly commit the transaction + except Exception as e: + session.rollback() # Rollback on error + raise HTTPException(status_code=400, detail=str(e)) + + +@ui_router.delete( + "/worker/{worker_name}/maintenance", +) +def exit_worker_maintenance( + worker_name: str, + session: SessionDep, +) -> None: + """Exit a worker from maintenance mode.""" + # Check if worker exists first + worker_query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name) + worker = session.scalar(worker_query) + if not worker: + raise HTTPException(status_code=404, detail=f"Worker {worker_name} not found") + + try: + exit_maintenance(worker_name, session=session) + session.commit() # Explicitly commit the transaction + except Exception as e: + session.rollback() # Rollback on error Review Comment: As above ```suggestion ``` ########## providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx: ########## @@ -16,22 +16,178 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Table } from "@chakra-ui/react"; +import { Box, Button, Table, Textarea, VStack, HStack } from "@chakra-ui/react"; import { useUiServiceWorker } from "openapi/queries"; +import type { Worker } from "openapi/requests/types.gen"; +import { useState } from "react"; import { ErrorAlert } from "src/components/ErrorAlert"; import { WorkerStateBadge } from "src/components/WorkerStateBadge"; import { autoRefreshInterval } from "src/utils"; +interface MaintenanceFormProps { + onSubmit: (comment: string) => void; + onCancel: () => void; +} + +const MaintenanceForm = ({ onCancel, onSubmit }: MaintenanceFormProps) => { + const [comment, setComment] = useState(""); + + const handleSubmit = () => { + if (comment.trim()) { + onSubmit(comment.trim()); + } + }; + + return ( + <VStack gap={2} align="stretch"> + <Textarea + placeholder="Enter maintenance comment (required)" + value={comment} + onChange={(e) => setComment(e.target.value)} + required + maxLength={1024} + size="sm" + /> + <HStack gap={2}> + <Button size="sm" colorScheme="blue" onClick={handleSubmit} disabled={!comment.trim()}> + Confirm Maintenance + </Button> + <Button size="sm" variant="outline" onClick={onCancel}> + Cancel + </Button> + </HStack> + </VStack> + ); +}; + export const WorkerPage = () => { - const { data, error } = useUiServiceWorker(undefined, { + const { data, error, refetch } = useUiServiceWorker(undefined, { enabled: true, refetchInterval: autoRefreshInterval, }); + const [activeMaintenanceForm, setActiveMaintenanceForm] = useState<string | null>(null); + + const requestMaintenance = async (workerName: string, comment: string) => { + try { + console.log(`Requesting maintenance for worker: ${workerName}, comment: ${comment}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = { + "Content-Type": "application/json", + }; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + body: JSON.stringify({ maintenance_comment: comment }), + credentials: "same-origin", + headers, + method: "POST", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Maintenance request failed:", response.status, errorText); + throw new Error(`Failed to request maintenance: ${response.status} ${errorText}`); + } + + console.log("Maintenance request successful"); + setActiveMaintenanceForm(null); + refetch(); + } catch (error) { + console.error("Error requesting maintenance:", error); + alert(`Error requesting maintenance: ${error}`); + } + }; + + const exitMaintenance = async (workerName: string) => { + try { + console.log(`Exiting maintenance for worker: ${workerName}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = {}; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + credentials: "same-origin", + headers, + method: "DELETE", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Exit maintenance failed:", response.status, errorText); + throw new Error(`Failed to exit maintenance: ${response.status} ${errorText}`); + } + + console.log("Exit maintenance successful"); + refetch(); + } catch (error) { + console.error("Error exiting maintenance:", error); + alert(`Error exiting maintenance: ${error}`); + } + }; + + const renderOperationsCell = (worker: Worker) => { + const workerName = worker.worker_name; + const state = worker.state; + + if (state === "idle" || state === "running") { + if (activeMaintenanceForm === workerName) { + return ( + <MaintenanceForm + onSubmit={(comment) => requestMaintenance(workerName, comment)} + onCancel={() => setActiveMaintenanceForm(null)} + /> + ); + } + return ( + <Button size="sm" colorScheme="blue" onClick={() => setActiveMaintenanceForm(workerName)}> + Enter Maintenance Review Comment: Instead of (like in the 2.x legacy UI) rendering buttons with text - have you considered we use icons like in other tables/lists in the core UI? Like here, icons on the right side: <img width="1036" height="326" alt="image" src="https://github.com/user-attachments/assets/3c9226b9-2c96-407a-96a0-bf05b52474ef" /> For maintenance the icon https://react-icons.github.io/react-icons/search/#q=HiOutlineWrenchScrewdriver might be suitable (using this already for the state display) ########## providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx: ########## @@ -16,22 +16,178 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Table } from "@chakra-ui/react"; +import { Box, Button, Table, Textarea, VStack, HStack } from "@chakra-ui/react"; import { useUiServiceWorker } from "openapi/queries"; +import type { Worker } from "openapi/requests/types.gen"; +import { useState } from "react"; import { ErrorAlert } from "src/components/ErrorAlert"; import { WorkerStateBadge } from "src/components/WorkerStateBadge"; import { autoRefreshInterval } from "src/utils"; +interface MaintenanceFormProps { + onSubmit: (comment: string) => void; + onCancel: () => void; +} + +const MaintenanceForm = ({ onCancel, onSubmit }: MaintenanceFormProps) => { + const [comment, setComment] = useState(""); + + const handleSubmit = () => { + if (comment.trim()) { + onSubmit(comment.trim()); + } + }; + + return ( + <VStack gap={2} align="stretch"> + <Textarea + placeholder="Enter maintenance comment (required)" + value={comment} + onChange={(e) => setComment(e.target.value)} + required + maxLength={1024} + size="sm" + /> + <HStack gap={2}> + <Button size="sm" colorScheme="blue" onClick={handleSubmit} disabled={!comment.trim()}> + Confirm Maintenance + </Button> + <Button size="sm" variant="outline" onClick={onCancel}> + Cancel + </Button> + </HStack> + </VStack> + ); +}; + export const WorkerPage = () => { - const { data, error } = useUiServiceWorker(undefined, { + const { data, error, refetch } = useUiServiceWorker(undefined, { enabled: true, refetchInterval: autoRefreshInterval, }); + const [activeMaintenanceForm, setActiveMaintenanceForm] = useState<string | null>(null); + + const requestMaintenance = async (workerName: string, comment: string) => { + try { + console.log(`Requesting maintenance for worker: ${workerName}, comment: ${comment}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = { + "Content-Type": "application/json", + }; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + body: JSON.stringify({ maintenance_comment: comment }), + credentials: "same-origin", + headers, + method: "POST", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Maintenance request failed:", response.status, errorText); + throw new Error(`Failed to request maintenance: ${response.status} ${errorText}`); + } + + console.log("Maintenance request successful"); + setActiveMaintenanceForm(null); + refetch(); + } catch (error) { + console.error("Error requesting maintenance:", error); + alert(`Error requesting maintenance: ${error}`); + } + }; + + const exitMaintenance = async (workerName: string) => { + try { + console.log(`Exiting maintenance for worker: ${workerName}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = {}; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + credentials: "same-origin", + headers, + method: "DELETE", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Exit maintenance failed:", response.status, errorText); + throw new Error(`Failed to exit maintenance: ${response.status} ${errorText}`); + } + + console.log("Exit maintenance successful"); + refetch(); + } catch (error) { + console.error("Error exiting maintenance:", error); + alert(`Error exiting maintenance: ${error}`); + } + }; + + const renderOperationsCell = (worker: Worker) => { + const workerName = worker.worker_name; + const state = worker.state; + + if (state === "idle" || state === "running") { + if (activeMaintenanceForm === workerName) { + return ( + <MaintenanceForm + onSubmit={(comment) => requestMaintenance(workerName, comment)} + onCancel={() => setActiveMaintenanceForm(null)} + /> + ); + } + return ( + <Button size="sm" colorScheme="blue" onClick={() => setActiveMaintenanceForm(workerName)}> + Enter Maintenance + </Button> + ); + } + + if ( + state === "maintenance pending" || + state === "maintenance mode" || + state === "maintenance request" || + state === "maintenance exit" || + state === "offline maintenance" + ) { + return ( + <VStack gap={2} align="stretch"> + <Box fontSize="sm" whiteSpace="pre-wrap"> + {worker.maintenance_comments || "No comment"} + </Box> + <Button size="sm" colorScheme="blue" onClick={() => exitMaintenance(workerName)}> Review Comment: If we make this UI form now "right" I assume we should add some kind of confirmation dialog/modal such that the user needs to confirm via a "are you sure you want to exit maintenance for worker XYZ? (yes/no)" ########## providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx: ########## @@ -16,22 +16,178 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Table } from "@chakra-ui/react"; +import { Box, Button, Table, Textarea, VStack, HStack } from "@chakra-ui/react"; import { useUiServiceWorker } from "openapi/queries"; +import type { Worker } from "openapi/requests/types.gen"; +import { useState } from "react"; import { ErrorAlert } from "src/components/ErrorAlert"; import { WorkerStateBadge } from "src/components/WorkerStateBadge"; import { autoRefreshInterval } from "src/utils"; +interface MaintenanceFormProps { + onSubmit: (comment: string) => void; + onCancel: () => void; +} + +const MaintenanceForm = ({ onCancel, onSubmit }: MaintenanceFormProps) => { + const [comment, setComment] = useState(""); + + const handleSubmit = () => { + if (comment.trim()) { + onSubmit(comment.trim()); + } + }; + + return ( + <VStack gap={2} align="stretch"> + <Textarea Review Comment: Would be a cool improvement over 2.x UI to be able to use Markdown rendering here? WDYT? We might need to clone the component from core-ui. Might be also a beautification as a follow-up PR. ########## providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx: ########## @@ -16,22 +16,178 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Table } from "@chakra-ui/react"; +import { Box, Button, Table, Textarea, VStack, HStack } from "@chakra-ui/react"; import { useUiServiceWorker } from "openapi/queries"; +import type { Worker } from "openapi/requests/types.gen"; +import { useState } from "react"; import { ErrorAlert } from "src/components/ErrorAlert"; import { WorkerStateBadge } from "src/components/WorkerStateBadge"; import { autoRefreshInterval } from "src/utils"; +interface MaintenanceFormProps { + onSubmit: (comment: string) => void; + onCancel: () => void; +} + +const MaintenanceForm = ({ onCancel, onSubmit }: MaintenanceFormProps) => { + const [comment, setComment] = useState(""); + + const handleSubmit = () => { + if (comment.trim()) { + onSubmit(comment.trim()); + } + }; + + return ( + <VStack gap={2} align="stretch"> + <Textarea + placeholder="Enter maintenance comment (required)" + value={comment} + onChange={(e) => setComment(e.target.value)} + required + maxLength={1024} + size="sm" + /> + <HStack gap={2}> + <Button size="sm" colorScheme="blue" onClick={handleSubmit} disabled={!comment.trim()}> + Confirm Maintenance + </Button> + <Button size="sm" variant="outline" onClick={onCancel}> + Cancel + </Button> + </HStack> + </VStack> + ); +}; + export const WorkerPage = () => { - const { data, error } = useUiServiceWorker(undefined, { + const { data, error, refetch } = useUiServiceWorker(undefined, { enabled: true, refetchInterval: autoRefreshInterval, }); + const [activeMaintenanceForm, setActiveMaintenanceForm] = useState<string | null>(null); + + const requestMaintenance = async (workerName: string, comment: string) => { + try { + console.log(`Requesting maintenance for worker: ${workerName}, comment: ${comment}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = { + "Content-Type": "application/json", + }; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + body: JSON.stringify({ maintenance_comment: comment }), + credentials: "same-origin", + headers, + method: "POST", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Maintenance request failed:", response.status, errorText); + throw new Error(`Failed to request maintenance: ${response.status} ${errorText}`); + } + + console.log("Maintenance request successful"); + setActiveMaintenanceForm(null); + refetch(); + } catch (error) { + console.error("Error requesting maintenance:", error); + alert(`Error requesting maintenance: ${error}`); + } + }; + + const exitMaintenance = async (workerName: string) => { + try { + console.log(`Exiting maintenance for worker: ${workerName}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = {}; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + credentials: "same-origin", + headers, + method: "DELETE", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Exit maintenance failed:", response.status, errorText); + throw new Error(`Failed to exit maintenance: ${response.status} ${errorText}`); + } + + console.log("Exit maintenance successful"); + refetch(); + } catch (error) { + console.error("Error exiting maintenance:", error); + alert(`Error exiting maintenance: ${error}`); + } + }; + + const renderOperationsCell = (worker: Worker) => { + const workerName = worker.worker_name; + const state = worker.state; + + if (state === "idle" || state === "running") { + if (activeMaintenanceForm === workerName) { + return ( + <MaintenanceForm + onSubmit={(comment) => requestMaintenance(workerName, comment)} + onCancel={() => setActiveMaintenanceForm(null)} + /> + ); + } + return ( + <Button size="sm" colorScheme="blue" onClick={() => setActiveMaintenanceForm(workerName)}> + Enter Maintenance + </Button> + ); + } + + if ( + state === "maintenance pending" || + state === "maintenance mode" || + state === "maintenance request" || + state === "maintenance exit" || Review Comment: I think when maintenance exit is already there as status we do not need to present the "Exit" button again, it is just pending that the worker fetches this. ```suggestion ``` ########## providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx: ########## @@ -16,22 +16,178 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Table } from "@chakra-ui/react"; +import { Box, Button, Table, Textarea, VStack, HStack } from "@chakra-ui/react"; import { useUiServiceWorker } from "openapi/queries"; +import type { Worker } from "openapi/requests/types.gen"; +import { useState } from "react"; import { ErrorAlert } from "src/components/ErrorAlert"; import { WorkerStateBadge } from "src/components/WorkerStateBadge"; import { autoRefreshInterval } from "src/utils"; +interface MaintenanceFormProps { + onSubmit: (comment: string) => void; + onCancel: () => void; +} + +const MaintenanceForm = ({ onCancel, onSubmit }: MaintenanceFormProps) => { + const [comment, setComment] = useState(""); + + const handleSubmit = () => { + if (comment.trim()) { + onSubmit(comment.trim()); + } + }; + + return ( + <VStack gap={2} align="stretch"> + <Textarea + placeholder="Enter maintenance comment (required)" + value={comment} + onChange={(e) => setComment(e.target.value)} + required + maxLength={1024} + size="sm" + /> + <HStack gap={2}> + <Button size="sm" colorScheme="blue" onClick={handleSubmit} disabled={!comment.trim()}> + Confirm Maintenance + </Button> + <Button size="sm" variant="outline" onClick={onCancel}> + Cancel + </Button> + </HStack> + </VStack> + ); +}; + export const WorkerPage = () => { - const { data, error } = useUiServiceWorker(undefined, { + const { data, error, refetch } = useUiServiceWorker(undefined, { enabled: true, refetchInterval: autoRefreshInterval, }); + const [activeMaintenanceForm, setActiveMaintenanceForm] = useState<string | null>(null); + + const requestMaintenance = async (workerName: string, comment: string) => { + try { + console.log(`Requesting maintenance for worker: ${workerName}, comment: ${comment}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = { + "Content-Type": "application/json", + }; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + body: JSON.stringify({ maintenance_comment: comment }), + credentials: "same-origin", + headers, + method: "POST", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Maintenance request failed:", response.status, errorText); + throw new Error(`Failed to request maintenance: ${response.status} ${errorText}`); + } + + console.log("Maintenance request successful"); + setActiveMaintenanceForm(null); + refetch(); + } catch (error) { + console.error("Error requesting maintenance:", error); + alert(`Error requesting maintenance: ${error}`); + } + }; + + const exitMaintenance = async (workerName: string) => { + try { + console.log(`Exiting maintenance for worker: ${workerName}`); + + // Get CSRF token from meta tag (common Airflow pattern) + const csrfToken = + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + document.querySelector('input[name="csrf_token"]')?.getAttribute("value"); + + const headers: Record<string, string> = {}; + + // Add CSRF token if available + if (csrfToken) { + headers["X-CSRFToken"] = csrfToken; + } + + const response = await fetch(`/edge_worker/ui/worker/${workerName}/maintenance`, { + credentials: "same-origin", + headers, + method: "DELETE", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Exit maintenance failed:", response.status, errorText); + throw new Error(`Failed to exit maintenance: ${response.status} ${errorText}`); + } + + console.log("Exit maintenance successful"); + refetch(); + } catch (error) { + console.error("Error exiting maintenance:", error); + alert(`Error exiting maintenance: ${error}`); + } + }; + + const renderOperationsCell = (worker: Worker) => { + const workerName = worker.worker_name; + const state = worker.state; + + if (state === "idle" || state === "running") { + if (activeMaintenanceForm === workerName) { + return ( + <MaintenanceForm + onSubmit={(comment) => requestMaintenance(workerName, comment)} + onCancel={() => setActiveMaintenanceForm(null)} + /> + ); + } + return ( + <Button size="sm" colorScheme="blue" onClick={() => setActiveMaintenanceForm(workerName)}> + Enter Maintenance + </Button> + ); + } + + if ( + state === "maintenance pending" || + state === "maintenance mode" || + state === "maintenance request" || + state === "maintenance exit" || + state === "offline maintenance" + ) { + return ( + <VStack gap={2} align="stretch"> + <Box fontSize="sm" whiteSpace="pre-wrap"> + {worker.maintenance_comments || "No comment"} + </Box> + <Button size="sm" colorScheme="blue" onClick={() => exitMaintenance(workerName)}> + Exit Maintenance + </Button> + </VStack> + ); + } + + return null; + }; Review Comment: This code part looks like it would be better extracted into a React component so that it can be directly plugged into the table cell like `<OperationsCell worker={ worker } />` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: commits-unsubscr...@airflow.apache.org For queries about this service, please contact Infrastructure at: us...@infra.apache.org