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

Reply via email to