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: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]