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 <dheerajtur...@gmail.com> 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 <jsche...@apache.org> --- .../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