This is an automated email from the ASF dual-hosted git repository.
jscheffl pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new acc17657858 Add worker maintenance mode functionality to Edge3
provider UI (#55301)
acc17657858 is described below
commit acc17657858c950cfe9046c06167a9cdb200477b
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Sun Sep 7 15:34:24 2025 -0500
Add worker maintenance mode functionality to Edge3 provider UI (#55301)
* Add worker maintenance mode functionality to Edge3 provider UI
- Add POST /edge_worker/ui/worker/{worker_name}/maintenance endpoint to
request maintenance mode
- Add DELETE /edge_worker/ui/worker/{worker_name}/maintenance endpoint to
exit maintenance mode
- Add maintenance request form with required comment field (max 1024
chars)
- Add maintenance state handling for all maintenance-related worker states
- Add proper error handling and user feedback for maintenance operations
- Add worker existence validation and database session management
- Remove authentication dependencies for UI maintenance endpoints
- Update frontend to show maintenance controls based on worker state
- Format maintenance comments with timestamps for audit trail
* Move MaintenanceRequest to datamodels_ui.py for better code organization
Remove explicit session.commit() calls - let SQLAlchemy handle transactions
* refactor: extract OperationsCell into separate component
- Move MaintenanceRequest class to datamodels_ui.py for better
organization
- Remove explicit session.commit() calls, let SQLAlchemy handle
transactions
- Extract OperationsCell and MaintenanceForm into separate component file
- Clean up unused imports in WorkerPage.tsx
- Improve code modularity and maintainability
* refactor: replace manual fetch with generated axios wrappers for
maintenance operations
- Use useUiServiceRequestWorkerMaintenance and
useUiServiceExitWorkerMaintenance hooks
- Remove manual fetch implementation with CSRF token handling
- Simplify error handling using React Query's onSuccess/onError callbacks
- Improve type safety with generated API client
- Reduce bundle size and code complexity
- Maintain same functionality with cleaner, more maintainable code
* Use react icons instead of old school buttons
* Add tooltips to buttons
* Have exit button not if it is in maintenance exit already
* Add authentication to (new) API endpoints
---------
Co-authored-by: Jens Scheffler <[email protected]>
---
.../providers/edge3/openapi/v2-edge-generated.yaml | 78 +++++++++++
.../providers/edge3/plugins/www/dist/main.umd.cjs | 34 ++---
.../plugins/www/openapi-gen/queries/common.ts | 2 +
.../plugins/www/openapi-gen/queries/queries.ts | 14 +-
.../www/openapi-gen/requests/schemas.gen.ts | 14 ++
.../www/openapi-gen/requests/services.gen.ts | 47 ++++++-
.../plugins/www/openapi-gen/requests/types.gen.ts | 51 +++++++
.../plugins/www/src/components/OperationsCell.tsx | 147 +++++++++++++++++++++
.../edge3/plugins/www/src/pages/WorkerPage.tsx | 58 +++++++-
.../providers/edge3/worker_api/datamodels_ui.py | 6 +
.../providers/edge3/worker_api/routes/ui.py | 60 ++++++++-
providers/edge3/www-hash.txt | 2 +-
12 files changed, 486 insertions(+), 27 deletions(-)
diff --git
a/providers/edge3/src/airflow/providers/edge3/openapi/v2-edge-generated.yaml
b/providers/edge3/src/airflow/providers/edge3/openapi/v2-edge-generated.yaml
index 6eabd5901f9..4619fc22235 100644
--- a/providers/edge3/src/airflow/providers/edge3/openapi/v2-edge-generated.yaml
+++ b/providers/edge3/src/airflow/providers/edge3/openapi/v2-edge-generated.yaml
@@ -586,6 +586,73 @@ paths:
security:
- OAuth2PasswordBearer: []
- HTTPBearer: []
+ /edge_worker/ui/worker/{worker_name}/maintenance:
+ post:
+ tags:
+ - UI
+ summary: Request Worker Maintenance
+ description: Put a worker into maintenance mode.
+ operationId: request_worker_maintenance
+ security:
+ - OAuth2PasswordBearer: []
+ - HTTPBearer: []
+ parameters:
+ - name: worker_name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Worker Name
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MaintenanceRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: 'null'
+ title: Response Request Worker Maintenance
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ tags:
+ - UI
+ summary: Exit Worker Maintenance
+ description: Exit a worker from maintenance mode.
+ operationId: exit_worker_maintenance
+ security:
+ - OAuth2PasswordBearer: []
+ - HTTPBearer: []
+ parameters:
+ - name: worker_name
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Worker Name
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: 'null'
+ title: Response Exit Worker Maintenance
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
components:
schemas:
BundleInfo:
@@ -794,6 +861,17 @@ components:
- total_entries
title: JobCollectionResponse
description: Job Collection serializer.
+ MaintenanceRequest:
+ properties:
+ maintenance_comment:
+ type: string
+ title: Maintenance Comment
+ description: Comment describing the maintenance reason.
+ type: object
+ required:
+ - maintenance_comment
+ title: MaintenanceRequest
+ description: Request body for maintenance operations.
PushLogsBody:
properties:
log_chunk_time:
diff --git
a/providers/edge3/src/airflow/providers/edge3/plugins/www/dist/main.umd.cjs
b/providers/edge3/src/airflow/providers/edge3/plugins/www/dist/main.umd.cjs
index 8a897c96b35..871159e89c8 100644
--- a/providers/edge3/src/airflow/providers/edge3/plugins/www/dist/main.umd.cjs
+++ b/providers/edge3/src/airflow/providers/edge3/plugins/www/dist/main.umd.cjs
@@ -1,4 +1,4 @@
-(function(A,Y){typeof exports=="object"&&typeof
module<"u"?module.exports=Y(require("react"),require("react-dom")):typeof
define=="function"&&define.amd?define(["react","react-dom"],Y):(A=typeof
globalThis<"u"?globalThis:A||self,A.AirflowPlugin=Y(A.React))})(this,function(A){"use
strict";var dT=Object.defineProperty;var Vm=A=>{throw TypeError(A)};var
hT=(A,Y,w)=>Y in
A?dT(A,Y,{enumerable:!0,configurable:!0,writable:!0,value:w}):A[Y]=w;var
qe=(A,Y,w)=>hT(A,typeof Y!="symbol"?Y+"":Y,w),bl= [...]
+(function(T,Y){typeof exports=="object"&&typeof
module<"u"?module.exports=Y(require("react"),require("react-dom")):typeof
define=="function"&&define.amd?define(["react","react-dom"],Y):(T=typeof
globalThis<"u"?globalThis:T||self,T.AirflowPlugin=Y(T.React))})(this,function(T){"use
strict";var ZT=Object.defineProperty;var uv=T=>{throw TypeError(T)};var
eN=(T,Y,w)=>Y in
T?ZT(T,Y,{enumerable:!0,configurable:!0,writable:!0,value:w}):T[Y]=w;var
Ke=(T,Y,w)=>eN(T,typeof Y!="symbol"?Y+"":Y,w),Vl= [...]
* @license React
* react-jsx-runtime.production.min.js
*
@@ -6,27 +6,27 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
- */var
Mm=A,Bm=Symbol.for("react.element"),$m=Symbol.for("react.fragment"),jm=Object.prototype.hasOwnProperty,Wm=Mm.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,Hm={key:!0,ref:!0,__self:!0,__source:!0};function
Ol(e,t,n){var r,i={},o=null,s=null;n!==void 0&&(o=""+n),t.key!==void
0&&(o=""+t.key),t.ref!==void 0&&(s=t.ref);for(r in
t)jm.call(t,r)&&!Hm.hasOwnProperty(r)&&(i[r]=t[r]);if(e&&e.defaultProps)for(r
in t=e.defaultProps,t)i[r]===void 0&&(i[r]=t[r]);return{$$t [...]
+ */var
pv=T,mv=Symbol.for("react.element"),vv=Symbol.for("react.fragment"),bv=Object.prototype.hasOwnProperty,yv=pv.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,xv={key:!0,ref:!0,__self:!0,__source:!0};function
Wl(e,t,n){var r,i={},o=null,s=null;n!==void 0&&(o=""+n),t.key!==void
0&&(o=""+t.key),t.ref!==void 0&&(s=t.ref);for(r in
t)bv.call(t,r)&&!xv.hasOwnProperty(r)&&(i[r]=t[r]);if(e&&e.defaultProps)for(r
in t=e.defaultProps,t)i[r]===void 0&&(i[r]=t[r]);return{$$t [...]
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
- */var ye=typeof
Symbol=="function"&&Symbol.for,hs=ye?Symbol.for("react.element"):60103,fs=ye?Symbol.for("react.portal"):60106,Ni=ye?Symbol.for("react.fragment"):60107,_i=ye?Symbol.for("react.strict_mode"):60108,Ai=ye?Symbol.for("react.profiler"):60114,Vi=ye?Symbol.for("react.provider"):60109,Li=ye?Symbol.for("react.context"):60110,gs=ye?Symbol.for("react.async_mode"):60111,Fi=ye?Symbol.for("react.concurrent_mode"):60111,zi=ye?Symbol.for("react.forward_ref"):60112,Di=ye?Symbol.for("react
[...]
+ */var xe=typeof
Symbol=="function"&&Symbol.for,Os=xe?Symbol.for("react.element"):60103,Is=xe?Symbol.for("react.portal"):60106,$i=xe?Symbol.for("react.fragment"):60107,Bi=xe?Symbol.for("react.strict_mode"):60108,ji=xe?Symbol.for("react.profiler"):60114,Wi=xe?Symbol.for("react.provider"):60109,Hi=xe?Symbol.for("react.context"):60110,Rs=xe?Symbol.for("react.async_mode"):60111,Ui=xe?Symbol.for("react.concurrent_mode"):60111,Gi=xe?Symbol.for("react.forward_ref"):60112,qi=xe?Symbol.for("react
[...]
<svg width="46" height="15" style="left: -15.5px; position: absolute;
top: 0; filter: drop-shadow(rgba(0, 0, 0, 0.4) 0px 1px 1.1px);">
<g transform="translate(2 3)">
<path fill-rule="evenodd" d="M 15 4.5L 15 2L 11.5 5.5L 15 9L 15 6.5L
31 6.5L 31 9L 34.5 5.5L 31 2L 31 4.5Z" style="stroke-width: 2px; stroke:
white;"></path>
<path fill-rule="evenodd" d="M 15 4.5L 15 2L 11.5 5.5L 15 9L 15 6.5L
31 6.5L 31 9L 34.5 5.5L 31 2L 31 4.5Z"></path>
</g>
- </svg>`,n.body.appendChild(r)};function
tC(e){if(!(!e||e.ownerDocument.activeElement!==e))try{const{selectionStart:t,selectionEnd:n,value:r}=e,i=r.substring(0,t),o=r.substring(n);return{start:t,end:n,value:r,beforeTxt:i,afterTxt:o}}catch{}}function
nC(e,t){if(!(!e||e.ownerDocument.activeElement!==e)){if(!t){e.setSelectionRange(e.value.length,e.value.length);return}try{const{value:n}=e,{beforeTxt:r="",afterTxt:i="",start:o}=t;let
s=n.length;if(n.endsWith(i))s=n.length-i.length;else [...]
+ </svg>`,n.body.appendChild(r)};function
MS(e){if(!(!e||e.ownerDocument.activeElement!==e))try{const{selectionStart:t,selectionEnd:n,value:r}=e,i=r.substring(0,t),o=r.substring(n);return{start:t,end:n,value:r,beforeTxt:i,afterTxt:o}}catch{}}function
$S(e,t){if(!(!e||e.ownerDocument.activeElement!==e)){if(!t){e.setSelectionRange(e.value.length,e.value.length);return}try{const{value:n}=e,{beforeTxt:r="",afterTxt:i="",start:o}=t;let
s=n.length;if(n.endsWith(i))s=n.length-i.length;else [...]
)+\\(\\s*max(-device)?-${e}`,"i"),min:new
RegExp(`\\(\\s*min(-device)?-${e}`,"i"),maxMin:new
RegExp(`(!?\\(\\s*max(-device)?-${e})(.|
-)+\\(\\s*min(-device)?-${e}`,"i"),max:new
RegExp(`\\(\\s*max(-device)?-${e}`,"i")}),ew=Kd("width"),tw=Kd("height"),Xd=e=>({isMin:th(e.minMax,e.maxMin,e.min),isMax:th(e.maxMin,e.minMax,e.max)}),{isMin:Na,isMax:Yd}=Xd(ew),{isMin:_a,isMax:Qd}=Xd(tw),Jd=/print/i,Zd=/^print$/i,nw=/(-?\d*\.?\d+)(ch|em|ex|px|rem)/,rw=/(\d)/,ti=Number.MAX_VALUE,iw={ch:8.8984375,em:16,rem:16,ex:8.296875,px:1};function
eh(e){const t=nw.exec(e)||(Na(e)||_a(e)?rw.exec(e):null);if(!t)return
ti;if(t[0]==="0")return 0; [...]
-`).forEach(function(s){i=s.indexOf(":"),n=s.substring(0,i).trim().toLowerCase(),r=s.substring(i+1).trim(),!(!n||t[n]&&bR[n])&&(n==="set-cookie"?t[n]?t[n].push(r):t[n]=[r]:t[n]=t[n]?t[n]+",
"+r:r)}),t},_f=Symbol("internals");function ui(e){return
e&&String(e).trim().toLowerCase()}function Bo(e){return
e===!1||e==null?e:k.isArray(e)?e.map(Bo):String(e)}function xR(e){const
t=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let
r;for(;r=n.exec(e);)t[r[1]]=r[2];return t}const SR=e=>/ [...]
-`)}getSetCookie(){return
this.get("set-cookie")||[]}get[Symbol.toStringTag](){return"AxiosHeaders"}static
from(t){return t instanceof this?t:new this(t)}static concat(t,...n){const
r=new this(t);return n.forEach(i=>r.set(i)),r}static accessor(t){const
r=(this[_f]=this[_f]={accessors:{}}).accessors,i=this.prototype;function
o(s){const a=ui(s);r[a]||(wR(i,s),r[a]=!0)}return
k.isArray(t)?t.forEach(o):o(t),this}};Ue.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-A
[...]
-`+o.map(Hf).join(`
-`):" "+Hf(o[0]):"as no adapter specified";throw new W("There is no suitable
adapter to dispatch the request "+s,"ERR_NOT_SUPPORT")}return
r},adapters:ol};function
sl(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw
new dr(null,e)}function Gf(e){return
sl(e),e.headers=Ue.from(e.headers),e.data=rl.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),Uf.getAdapter(e.ad
[...]
-`+o):r.stack=o}catch{}}throw r}}_request(t,n){typeof
t=="string"?(n=n||{},n.url=t):n=t||{},n=Pn(this.defaults,n);const{transitional:r,paramsSerializer:i,headers:o}=n;r!==void
0&&Uo.assertOptions(r,{silentJSONParsing:It.transitional(It.boolean),forcedJSONParsing:It.transitional(It.boolean),clarifyTimeoutError:It.transitional(It.boolean)},!1),i!=null&&(k.isFunction(i)?n.paramsSerializer={serialize:i}:Uo.assertOptions(i,{encode:It.function,serialize:It.function},!0)),n.allowAbsoluteUrls!==v
[...]
+)+\\(\\s*min(-device)?-${e}`,"i"),max:new
RegExp(`\\(\\s*max(-device)?-${e}`,"i")}),Dw=mh("width"),Mw=mh("height"),vh=e=>({isMin:wh(e.minMax,e.maxMin,e.min),isMax:wh(e.maxMin,e.minMax,e.max)}),{isMin:Ga,isMax:bh}=vh(Dw),{isMin:qa,isMax:yh}=vh(Mw),xh=/print/i,Ch=/^print$/i,$w=/(-?\d*\.?\d+)(ch|em|ex|px|rem)/,Bw=/(\d)/,di=Number.MAX_VALUE,jw={ch:8.8984375,em:16,rem:16,ex:8.296875,px:1};function
Sh(e){const t=$w.exec(e)||(Ga(e)||qa(e)?Bw.exec(e):null);if(!t)return
di;if(t[0]==="0")return 0; [...]
+`).forEach(function(s){i=s.indexOf(":"),n=s.substring(0,i).trim().toLowerCase(),r=s.substring(i+1).trim(),!(!n||t[n]&&e2[n])&&(n==="set-cookie"?t[n]?t[n].push(r):t[n]=[r]:t[n]=t[n]?t[n]+",
"+r:r)}),t},lg=Symbol("internals");function yi(e){return
e&&String(e).trim().toLowerCase()}function Yo(e){return
e===!1||e==null?e:k.isArray(e)?e.map(Yo):String(e)}function n2(e){const
t=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let
r;for(;r=n.exec(e);)t[r[1]]=r[2];return t}const r2=e=>/ [...]
+`)}getSetCookie(){return
this.get("set-cookie")||[]}get[Symbol.toStringTag](){return"AxiosHeaders"}static
from(t){return t instanceof this?t:new this(t)}static concat(t,...n){const
r=new this(t);return n.forEach(i=>r.set(i)),r}static accessor(t){const
r=(this[lg]=this[lg]={accessors:{}}).accessors,i=this.prototype;function
o(s){const a=yi(s);r[a]||(o2(i,s),r[a]=!0)}return
k.isArray(t)?t.forEach(o):o(t),this}};Ue.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-A
[...]
+`+o.map(xg).join(`
+`):" "+xg(o[0]):"as no adapter specified";throw new W("There is no suitable
adapter to dispatch the request "+s,"ERR_NOT_SUPPORT")}return
r},adapters:xl};function
Cl(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw
new Cr(null,e)}function Sg(e){return
Cl(e),e.headers=Ue.from(e.headers),e.data=bl.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),Cg.getAdapter(e.ad
[...]
+`+o):r.stack=o}catch{}}throw r}}_request(t,n){typeof
t=="string"?(n=n||{},n.url=t):n=t||{},n=zn(this.defaults,n);const{transitional:r,paramsSerializer:i,headers:o}=n;r!==void
0&&ts.assertOptions(r,{silentJSONParsing:Pt.transitional(Pt.boolean),forcedJSONParsing:Pt.transitional(Pt.boolean),clarifyTimeoutError:Pt.transitional(Pt.boolean)},!1),i!=null&&(k.isFunction(i)?n.paramsSerializer={serialize:i}:ts.assertOptions(i,{encode:Pt.function,serialize:Pt.function},!0)),n.allowAbsoluteUrls!==v
[...]
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
@@ -35,7 +35,7 @@
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
- */function di(){return
di=Object.assign?Object.assign.bind():function(e){for(var
t=1;t<arguments.length;t++){var n=arguments[t];for(var r in
n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return
e},di.apply(this,arguments)}var
Jt;(function(e){e.Pop="POP",e.Push="PUSH",e.Replace="REPLACE"})(Jt||(Jt={}));const
Zf="popstate";function JR(e){e===void 0&&(e={});function
t(r,i){let{pathname:o,search:s,hash:a}=r.location;return
cl("",{pathname:o,search:s,hash:a},i.state&&i.state.usr|| [...]
+ */function xi(){return
xi=Object.assign?Object.assign.bind():function(e){for(var
t=1;t<arguments.length;t++){var n=arguments[t];for(var r in
n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return
e},xi.apply(this,arguments)}var
tn;(function(e){e.Pop="POP",e.Push="PUSH",e.Replace="REPLACE"})(tn||(tn={}));const
Pg="popstate";function V2(e){e===void 0&&(e={});function
t(r,i){let{pathname:o,search:s,hash:a}=r.location;return
kl("",{pathname:o,search:s,hash:a},i.state&&i.state.usr|| [...]
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
@@ -44,7 +44,7 @@
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
- */function hi(){return
hi=Object.assign?Object.assign.bind():function(e){for(var
t=1;t<arguments.length;t++){var n=arguments[t];for(var r in
n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return
e},hi.apply(this,arguments)}const
Ko=w.createContext(null),cg=w.createContext(null),en=w.createContext(null),Xo=w.createContext(null),Nn=w.createContext({outlet:null,matches:[],isDataRoute:!1}),ug=w.createContext(null);function
wP(e,t){let{relative:n}=t===void 0?{}:t;fi()||ue(!1);let{b [...]
+ */function Ci(){return
Ci=Object.assign?Object.assign.bind():function(e){for(var
t=1;t<arguments.length;t++){var n=arguments[t];for(var r in
n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return
e},Ci.apply(this,arguments)}const
is=w.createContext(null),Mg=w.createContext(null),rn=w.createContext(null),os=w.createContext(null),Mn=w.createContext({outlet:null,matches:[],isDataRoute:!1}),$g=w.createContext(null);function
oP(e,t){let{relative:n}=t===void 0?{}:t;Si()||de(!1);let{b [...]
* React Router DOM v6.30.1
*
* Copyright (c) Remix Software Inc.
@@ -53,7 +53,7 @@
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
- */function Qo(){return
Qo=Object.assign?Object.assign.bind():function(e){for(var
t=1;t<arguments.length;t++){var n=arguments[t];for(var r in
n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return
e},Qo.apply(this,arguments)}function mg(e,t){if(e==null)return{};var
n={},r=Object.keys(e),i,o;for(o=0;o<r.length;o++)i=r[o],!(t.indexOf(i)>=0)&&(n[i]=e[i]);return
n}function jP(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function
WP(e,t){return e.button===0&&(!t||t==="_sel [...]
+ */function as(){return
as=Object.assign?Object.assign.bind():function(e){for(var
t=1;t<arguments.length;t++){var n=arguments[t];for(var r in
n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return
e},as.apply(this,arguments)}function Gg(e,t){if(e==null)return{};var
n={},r=Object.keys(e),i,o;for(o=0;o<r.length;o++)i=r[o],!(t.indexOf(i)>=0)&&(n[i]=e[i]);return
n}function kP(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function
EP(e,t){return e.button===0&&(!t||t==="_sel [...]
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
@@ -70,7 +70,7 @@
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
- */const Vg=5e3;/*!
+ */const ap=5e3;/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
@@ -87,7 +87,7 @@
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
- */const H2=e=>{const[t,n]=A.useState(0);return
A.useEffect(()=>{if(!e.current)return;const r=new ResizeObserver(i=>{for(const
o of i)n(o.contentRect.width)});return
r.observe(e.current),()=>{r.disconnect()}},[e]),t};/*!
+ */const RT=e=>{const[t,n]=T.useState(0);return
T.useEffect(()=>{if(!e.current)return;const r=new ResizeObserver(i=>{for(const
o of i)n(o.contentRect.width)});return
r.observe(e.current),()=>{r.disconnect()}},[e]),t};/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
@@ -104,7 +104,7 @@
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
- */const Lg="token",U2=()=>{const e=document.cookie.split(";");for(const t of
e){const[n,r]=t.split("=");if((n==null?void 0:n.trim())==="_token"&&r!==void
0)return localStorage.setItem(Lg,r),document.cookie="_token=; expires=Sat, 01
Jan 2000 00:00:00 UTC; path=/;",r}},G2=e=>{const
t=localStorage.getItem(Lg)??U2();return t!==void
0&&(e.headers.Authorization=`Bearer
${t}`),e},q2=()=>{const{data:e,error:t}=x2(void
0,{enabled:!0,refetchInterval:Vg});return e?C.jsx($t,{p:2,children:C.jsxs(Hh,
[...]
+ */const lp="token",PT=()=>{const e=document.cookie.split(";");for(const t of
e){const[n,r]=t.split("=");if((n==null?void 0:n.trim())==="_token"&&r!==void
0)return localStorage.setItem(lp,r),document.cookie="_token=; expires=Sat, 01
Jan 2000 00:00:00 UTC; path=/;",r}},TT=e=>{const
t=localStorage.getItem(lp)??PT();return t!==void
0&&(e.headers.Authorization=`Bearer
${t}`),e},NT=()=>{const{data:e,error:t}=nT(void
0,{enabled:!0,refetchInterval:ap});return e?S.jsx(At,{p:2,children:S.jsxs(mf,
[...]
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
@@ -121,4 +121,4 @@
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
- */const
tn=(e,t="white")=>({solid:{value:`{colors.${e}.600}`},contrast:{value:{_light:"white",_dark:t}},fg:{value:{_light:`{colors.${e}.800}`,_dark:`{colors.${e}.200}`}},muted:{value:{_light:`{colors.${e}.200}`,_dark:`{colors.${e}.800}`}},subtle:{value:{_light:`{colors.${e}.100}`,_dark:`{colors.${e}.900}`}},emphasized:{value:{_light:`{colors.${e}.300}`,_dark:`{colors.${e}.700}`}},focusRing:{value:{_light:`{colors.${e}.800}`,_dark:`{colors.${e}.200}`}}}),oT=Ia({theme:{tokens:{colors:{suc
[...]
+ */const
on=(e,t="white")=>({solid:{value:`{colors.${e}.600}`},contrast:{value:{_light:"white",_dark:t}},fg:{value:{_light:`{colors.${e}.800}`,_dark:`{colors.${e}.200}`}},muted:{value:{_light:`{colors.${e}.200}`,_dark:`{colors.${e}.800}`}},subtle:{value:{_light:`{colors.${e}.100}`,_dark:`{colors.${e}.900}`}},emphasized:{value:{_light:`{colors.${e}.300}`,_dark:`{colors.${e}.700}`}},focusRing:{value:{_light:`{colors.${e}.800}`,_dark:`{colors.${e}.200}`}}}),qT=ja({theme:{tokens:{colors:{suc
[...]
diff --git
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/queries/common.ts
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/queries/common.ts
index a6bd6241897..43908424fb0 100644
---
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/queries/common.ts
+++
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/queries/common.ts
@@ -28,6 +28,8 @@ export const UseUiServiceJobsKeyFn = (queryKey?:
Array<unknown>) => [useUiServic
export type JobsServiceFetchMutationResult = Awaited<ReturnType<typeof
JobsService.fetch>>;
export type LogsServicePushLogsMutationResult = Awaited<ReturnType<typeof
LogsService.pushLogs>>;
export type WorkerServiceRegisterMutationResult = Awaited<ReturnType<typeof
WorkerService.register>>;
+export type UiServiceRequestWorkerMaintenanceMutationResult =
Awaited<ReturnType<typeof UiService.requestWorkerMaintenance>>;
export type JobsServiceStateMutationResult = Awaited<ReturnType<typeof
JobsService.state>>;
export type WorkerServiceSetStateMutationResult = Awaited<ReturnType<typeof
WorkerService.setState>>;
export type WorkerServiceUpdateQueuesMutationResult =
Awaited<ReturnType<typeof WorkerService.updateQueues>>;
+export type UiServiceExitWorkerMaintenanceMutationResult =
Awaited<ReturnType<typeof UiService.exitWorkerMaintenance>>;
diff --git
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/queries/queries.ts
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/queries/queries.ts
index cb94b352977..c23397614c2 100644
---
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/queries/queries.ts
+++
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/queries/queries.ts
@@ -2,7 +2,7 @@
import { UseMutationOptions, UseQueryOptions, useMutation, useQuery } from
"@tanstack/react-query";
import { JobsService, LogsService, MonitorService, UiService, WorkerService }
from "../requests/services.gen";
-import { PushLogsBody, TaskInstanceState, WorkerQueueUpdateBody,
WorkerQueuesBody, WorkerStateBody } from "../requests/types.gen";
+import { MaintenanceRequest, PushLogsBody, TaskInstanceState,
WorkerQueueUpdateBody, WorkerQueuesBody, WorkerStateBody } from
"../requests/types.gen";
import * as Common from "./common";
export const useLogsServiceLogfilePath = <TData =
Common.LogsServiceLogfilePathDefaultResponse, TError = unknown, TQueryKey
extends Array<unknown> = unknown[]>({ authorization, dagId, mapIndex, runId,
taskId, tryNumber }: {
authorization: string;
@@ -50,6 +50,13 @@ export const useWorkerServiceRegister = <TData =
Common.WorkerServiceRegisterMut
requestBody: WorkerStateBody;
workerName: string;
}, TContext>({ mutationFn: ({ authorization, requestBody, workerName }) =>
WorkerService.register({ authorization, requestBody, workerName }) as unknown
as Promise<TData>, ...options });
+export const useUiServiceRequestWorkerMaintenance = <TData =
Common.UiServiceRequestWorkerMaintenanceMutationResult, TError = unknown,
TContext = unknown>(options?: Omit<UseMutationOptions<TData, TError, {
+ requestBody: MaintenanceRequest;
+ workerName: string;
+}, TContext>, "mutationFn">) => useMutation<TData, TError, {
+ requestBody: MaintenanceRequest;
+ workerName: string;
+}, TContext>({ mutationFn: ({ requestBody, workerName }) =>
UiService.requestWorkerMaintenance({ requestBody, workerName }) as unknown as
Promise<TData>, ...options });
export const useJobsServiceState = <TData =
Common.JobsServiceStateMutationResult, TError = unknown, TContext =
unknown>(options?: Omit<UseMutationOptions<TData, TError, {
authorization: string;
dagId: string;
@@ -85,3 +92,8 @@ export const useWorkerServiceUpdateQueues = <TData =
Common.WorkerServiceUpdateQ
requestBody: WorkerQueueUpdateBody;
workerName: string;
}, TContext>({ mutationFn: ({ authorization, requestBody, workerName }) =>
WorkerService.updateQueues({ authorization, requestBody, workerName }) as
unknown as Promise<TData>, ...options });
+export const useUiServiceExitWorkerMaintenance = <TData =
Common.UiServiceExitWorkerMaintenanceMutationResult, TError = unknown, TContext
= unknown>(options?: Omit<UseMutationOptions<TData, TError, {
+ workerName: string;
+}, TContext>, "mutationFn">) => useMutation<TData, TError, {
+ workerName: string;
+}, TContext>({ mutationFn: ({ workerName }) =>
UiService.exitWorkerMaintenance({ workerName }) as unknown as Promise<TData>,
...options });
diff --git
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/schemas.gen.ts
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/schemas.gen.ts
index f542a791a9f..d605e434669 100644
---
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/schemas.gen.ts
+++
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/schemas.gen.ts
@@ -252,6 +252,20 @@ export const $JobCollectionResponse = {
description: 'Job Collection serializer.'
} as const;
+export const $MaintenanceRequest = {
+ properties: {
+ maintenance_comment: {
+ type: 'string',
+ title: 'Maintenance Comment',
+ description: 'Comment describing the maintenance reason.'
+ }
+ },
+ type: 'object',
+ required: ['maintenance_comment'],
+ title: 'MaintenanceRequest',
+ description: 'Request body for maintenance operations.'
+} as const;
+
export const $PushLogsBody = {
properties: {
log_chunk_time: {
diff --git
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/services.gen.ts
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/services.gen.ts
index 4d7a7b0b951..728a1ae5507 100644
---
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/services.gen.ts
+++
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/services.gen.ts
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
-import type { FetchData, FetchResponse, StateData, StateResponse,
LogfilePathData, LogfilePathResponse, PushLogsData, PushLogsResponse,
RegisterData, RegisterResponse, SetStateData, SetStateResponse,
UpdateQueuesData, UpdateQueuesResponse, HealthResponse, WorkerResponse,
JobsResponse } from './types.gen';
+import type { FetchData, FetchResponse, StateData, StateResponse,
LogfilePathData, LogfilePathResponse, PushLogsData, PushLogsResponse,
RegisterData, RegisterResponse, SetStateData, SetStateResponse,
UpdateQueuesData, UpdateQueuesResponse, HealthResponse, WorkerResponse,
JobsResponse, RequestWorkerMaintenanceData, RequestWorkerMaintenanceResponse,
ExitWorkerMaintenanceData, ExitWorkerMaintenanceResponse } from './types.gen';
export class JobsService {
/**
@@ -286,4 +286,49 @@ export class UiService {
});
}
+ /**
+ * Request Worker Maintenance
+ * Put a worker into maintenance mode.
+ * @param data The data for the request.
+ * @param data.workerName
+ * @param data.requestBody
+ * @returns null Successful Response
+ * @throws ApiError
+ */
+ public static requestWorkerMaintenance(data:
RequestWorkerMaintenanceData):
CancelablePromise<RequestWorkerMaintenanceResponse> {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/edge_worker/ui/worker/{worker_name}/maintenance',
+ path: {
+ worker_name: data.workerName
+ },
+ body: data.requestBody,
+ mediaType: 'application/json',
+ errors: {
+ 422: 'Validation Error'
+ }
+ });
+ }
+
+ /**
+ * Exit Worker Maintenance
+ * Exit a worker from maintenance mode.
+ * @param data The data for the request.
+ * @param data.workerName
+ * @returns null Successful Response
+ * @throws ApiError
+ */
+ public static exitWorkerMaintenance(data: ExitWorkerMaintenanceData):
CancelablePromise<ExitWorkerMaintenanceResponse> {
+ return __request(OpenAPI, {
+ method: 'DELETE',
+ url: '/edge_worker/ui/worker/{worker_name}/maintenance',
+ path: {
+ worker_name: data.workerName
+ },
+ errors: {
+ 422: 'Validation Error'
+ }
+ });
+ }
+
}
\ No newline at end of file
diff --git
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/types.gen.ts
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/types.gen.ts
index 53198713128..61bf7663a9a 100644
---
a/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/types.gen.ts
+++
b/providers/edge3/src/airflow/providers/edge3/plugins/www/openapi-gen/requests/types.gen.ts
@@ -126,6 +126,16 @@ export type JobCollectionResponse = {
total_entries: number;
};
+/**
+ * Request body for maintenance operations.
+ */
+export type MaintenanceRequest = {
+ /**
+ * Comment describing the maintenance reason.
+ */
+ maintenance_comment: string;
+};
+
/**
* Incremental new log content from worker.
*/
@@ -460,6 +470,19 @@ export type WorkerResponse = WorkerCollectionResponse;
export type JobsResponse = JobCollectionResponse;
+export type RequestWorkerMaintenanceData = {
+ requestBody: MaintenanceRequest;
+ workerName: string;
+};
+
+export type RequestWorkerMaintenanceResponse = null;
+
+export type ExitWorkerMaintenanceData = {
+ workerName: string;
+};
+
+export type ExitWorkerMaintenanceResponse = null;
+
export type $OpenApiTs = {
'/edge_worker/v1/jobs/fetch/{worker_name}': {
post: {
@@ -652,4 +675,32 @@ export type $OpenApiTs = {
};
};
};
+ '/edge_worker/ui/worker/{worker_name}/maintenance': {
+ post: {
+ req: RequestWorkerMaintenanceData;
+ res: {
+ /**
+ * Successful Response
+ */
+ 200: null;
+ /**
+ * Validation Error
+ */
+ 422: HTTPValidationError;
+ };
+ };
+ delete: {
+ req: ExitWorkerMaintenanceData;
+ res: {
+ /**
+ * Successful Response
+ */
+ 200: null;
+ /**
+ * Validation Error
+ */
+ 422: HTTPValidationError;
+ };
+ };
+ };
};
\ No newline at end of file
diff --git
a/providers/edge3/src/airflow/providers/edge3/plugins/www/src/components/OperationsCell.tsx
b/providers/edge3/src/airflow/providers/edge3/plugins/www/src/components/OperationsCell.tsx
new file mode 100644
index 00000000000..bf7fe3b4d94
--- /dev/null
+++
b/providers/edge3/src/airflow/providers/edge3/plugins/www/src/components/OperationsCell.tsx
@@ -0,0 +1,147 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Box, Flex, HStack, IconButton, Textarea, VStack } from
"@chakra-ui/react";
+import type { Worker } from "openapi/requests/types.gen";
+import { useState } from "react";
+import { FcCheckmark } from "react-icons/fc";
+import { HiOutlineWrenchScrewdriver } from "react-icons/hi2";
+import { ImCross } from "react-icons/im";
+import { IoMdExit } from "react-icons/io";
+
+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}>
+ <IconButton
+ size="sm"
+ colorScheme="green"
+ onClick={handleSubmit}
+ disabled={!comment.trim()}
+ aria-label="Confirm Maintenance"
+ title="Confirm Maintenance"
+ >
+ <FcCheckmark />
+ </IconButton>
+ <IconButton
+ size="sm"
+ colorScheme="red"
+ variant="outline"
+ onClick={onCancel}
+ aria-label="Cancel"
+ title="Cancel"
+ >
+ <ImCross />
+ </IconButton>
+ </HStack>
+ </VStack>
+ );
+};
+
+interface OperationsCellProps {
+ worker: Worker;
+ activeMaintenanceForm: string | null;
+ onSetActiveMaintenanceForm: (workerName: string | null) => void;
+ onRequestMaintenance: (workerName: string, comment: string) => void;
+ onExitMaintenance: (workerName: string) => void;
+}
+
+export const OperationsCell = ({
+ activeMaintenanceForm,
+ onExitMaintenance,
+ onRequestMaintenance,
+ onSetActiveMaintenanceForm,
+ worker,
+}: OperationsCellProps) => {
+ const workerName = worker.worker_name;
+ const state = worker.state;
+
+ let cellContent = null;
+
+ if (state === "idle" || state === "running") {
+ if (activeMaintenanceForm === workerName) {
+ cellContent = (
+ <MaintenanceForm
+ onSubmit={(comment) => onRequestMaintenance(workerName, comment)}
+ onCancel={() => onSetActiveMaintenanceForm(null)}
+ />
+ );
+ } else {
+ cellContent = (
+ <Flex justifyContent="end">
+ <IconButton
+ size="sm"
+ variant="ghost"
+ onClick={() => onSetActiveMaintenanceForm(workerName)}
+ aria-label="Enter Maintenance"
+ title="Enter Maintenance"
+ >
+ <HiOutlineWrenchScrewdriver />
+ </IconButton>
+ </Flex>
+ );
+ }
+ } else if (
+ state === "maintenance pending" ||
+ state === "maintenance mode" ||
+ state === "maintenance request" ||
+ state === "offline maintenance"
+ ) {
+ cellContent = (
+ <VStack gap={2} align="stretch">
+ <Box fontSize="sm" whiteSpace="pre-wrap">
+ {worker.maintenance_comments || "No comment"}
+ </Box>
+ <Flex justifyContent="end">
+ <IconButton
+ size="sm"
+ variant="ghost"
+ onClick={() => onExitMaintenance(workerName)}
+ aria-label="Exit Maintenance"
+ title="Exit Maintenance"
+ >
+ <IoMdExit />
+ </IconButton>
+ </Flex>
+ </VStack>
+ );
+ }
+
+ return cellContent;
+};
diff --git
a/providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx
b/providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx
index 2af2adb6ecf..ff6a1745532 100644
---
a/providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx
+++
b/providers/edge3/src/airflow/providers/edge3/plugins/www/src/pages/WorkerPage.tsx
@@ -17,21 +17,63 @@
* under the License.
*/
import { Box, Table } from "@chakra-ui/react";
-import { useUiServiceWorker } from "openapi/queries";
+import {
+ useUiServiceWorker,
+ useUiServiceRequestWorkerMaintenance,
+ useUiServiceExitWorkerMaintenance,
+} from "openapi/queries";
+import { useState } from "react";
import { ErrorAlert } from "src/components/ErrorAlert";
+import { OperationsCell } from "src/components/OperationsCell";
import { WorkerStateBadge } from "src/components/WorkerStateBadge";
import { autoRefreshInterval } from "src/utils";
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 requestMaintenanceMutation = useUiServiceRequestWorkerMaintenance({
+ onError: (error) => {
+ console.error("Error requesting maintenance:", error);
+ alert(`Error requesting maintenance: ${error}`);
+ },
+ onSuccess: () => {
+ console.log("Maintenance request successful");
+ setActiveMaintenanceForm(null);
+ refetch();
+ },
+ });
+
+ const exitMaintenanceMutation = useUiServiceExitWorkerMaintenance({
+ onError: (error) => {
+ console.error("Error exiting maintenance:", error);
+ alert(`Error exiting maintenance: ${error}`);
+ },
+ onSuccess: () => {
+ console.log("Exit maintenance successful");
+ refetch();
+ },
+ });
+
+ const requestMaintenance = (workerName: string, comment: string) => {
+ console.log(`Requesting maintenance for worker: ${workerName}, comment:
${comment}`);
+ requestMaintenanceMutation.mutate({
+ requestBody: { maintenance_comment: comment },
+ workerName,
+ });
+ };
+
+ const exitMaintenance = (workerName: string) => {
+ console.log(`Exiting maintenance for worker: ${workerName}`);
+ exitMaintenanceMutation.mutate({ workerName });
+ };
// TODO to make it proper
// Use DataTable as component from Airflow-Core UI
- // Add actions for maintenance / delete of orphan worker
// Add sorting
// Add filtering
// Add links to see jobs on worker
@@ -86,7 +128,15 @@ export const WorkerPage = () => {
"N/A"
)}
</Table.Cell>
- <Table.Cell>{worker.maintenance_comments}</Table.Cell>
+ <Table.Cell>
+ <OperationsCell
+ worker={worker}
+ activeMaintenanceForm={activeMaintenanceForm}
+ onSetActiveMaintenanceForm={setActiveMaintenanceForm}
+ onRequestMaintenance={requestMaintenance}
+ onExitMaintenance={exitMaintenance}
+ />
+ </Table.Cell>
</Table.Row>
))}
</Table.Body>
diff --git
a/providers/edge3/src/airflow/providers/edge3/worker_api/datamodels_ui.py
b/providers/edge3/src/airflow/providers/edge3/worker_api/datamodels_ui.py
index f2e270809f2..70aa651d50c 100644
--- a/providers/edge3/src/airflow/providers/edge3/worker_api/datamodels_ui.py
+++ b/providers/edge3/src/airflow/providers/edge3/worker_api/datamodels_ui.py
@@ -65,3 +65,9 @@ class JobCollectionResponse(BaseModel):
jobs: list[Job]
total_entries: int
+
+
+class MaintenanceRequest(BaseModel):
+ """Request body for maintenance operations."""
+
+ maintenance_comment: Annotated[str, Field(description="Comment describing
the maintenance reason.")]
diff --git
a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py
b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py
index 620c0df8b25..11027310768 100644
--- a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py
+++ b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/ui.py
@@ -17,18 +17,21 @@
from __future__ import annotations
-from fastapi import Depends
+from datetime import datetime
+
+from fastapi import Depends, HTTPException
from sqlalchemy import select
from airflow.api_fastapi.auth.managers.models.resource_details import
AccessView
from airflow.api_fastapi.common.db.common import SessionDep # noqa: TC001
from airflow.api_fastapi.common.router import AirflowRouter
-from airflow.api_fastapi.core_api.security import requires_access_view
+from airflow.api_fastapi.core_api.security import GetUserDep,
requires_access_view
from airflow.providers.edge3.models.edge_job import EdgeJobModel
-from airflow.providers.edge3.models.edge_worker import EdgeWorkerModel
+from airflow.providers.edge3.models.edge_worker import EdgeWorkerModel,
exit_maintenance, request_maintenance
from airflow.providers.edge3.worker_api.datamodels_ui import (
Job,
JobCollectionResponse,
+ MaintenanceRequest,
Worker,
WorkerCollectionResponse,
)
@@ -100,3 +103,54 @@ def jobs(
jobs=result,
total_entries=len(result),
)
+
+
+@ui_router.post(
+ "/worker/{worker_name}/maintenance",
+ dependencies=[
+ Depends(requires_access_view(access_view=AccessView.JOBS)),
+ ],
+)
+def request_worker_maintenance(
+ worker_name: str,
+ maintenance_request: MaintenanceRequest,
+ session: SessionDep,
+ user: GetUserDep,
+) -> 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')}] -
{user.get_name()} put node into maintenance mode\nComment:
{maintenance_request.maintenance_comment}"
+
+ try:
+ request_maintenance(worker_name, formatted_comment, session=session)
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@ui_router.delete(
+ "/worker/{worker_name}/maintenance",
+ dependencies=[
+ Depends(requires_access_view(access_view=AccessView.JOBS)),
+ ],
+)
+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)
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
diff --git a/providers/edge3/www-hash.txt b/providers/edge3/www-hash.txt
index 41772bc2d03..0e053e9262d 100644
--- a/providers/edge3/www-hash.txt
+++ b/providers/edge3/www-hash.txt
@@ -1 +1 @@
-d780858c70355a081b2486871c18d5f3c579f04129b3b15e7c6f691a1a3a2c12
+ed85f7d6558cdc8c5edf498fcd96e187484327b877e021314f94ae640d4634f2