feat(portal): migrate V3 console to React 19 + Vite + TypeScript + Zustand + Tailwind v4
This commit is contained in:
1 parent
518f1e757e
commit
eae4596aa3
20 files changed
+4162
-3
No files matched your search
@@ -2,3 +2,4 @@
|
||||
stacks/gateway/certs/*.key
|
||||
stacks/gateway/certs/*.pem
|
||||
stacks/lucky/goodluck/
|
||||
**/node_modules/
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
(function(){const A=document.createElement("link").relList;if(A&&A.supports&&A.supports("modulepreload"))return;for(const p of document.querySelectorAll('link[rel="modulepreload"]'))m(p);new MutationObserver(p=>{for(const q of p)if(q.type==="childList")for(const nl of q.addedNodes)nl.tagName==="LINK"&&nl.rel==="modulepreload"&&m(nl)}).observe(document,{childList:!0,subtree:!0});function _(p){const q={};return p.integrity&&(q.integrity=p.integrity),p.referrerPolicy&&(q.referrerPolicy=p.referrerPolicy),p.crossOrigin==="use-credentials"?q.credentials="include":p.crossOrigin==="anonymous"?q.credentials="omit":q.credentials="same-origin",q}function m(p){if(p.ep)return;p.ep=!0;const q=_(p);fetch(p.href,q)}})();function o0(b){return b&&b.__esModule&&Object.prototype.hasOwnProperty.call(b,"default")?b.default:b}var Ts={exports:{}},an={};/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Ir;function dh(){if(Ir)return an;Ir=1;var b=Symbol.for("react.transitional.element"),A=Symbol.for("react.fragment");function _(m,p,q){var nl=null;if(q!==void 0&&(nl=""+q),p.key!==void 0&&(nl=""+p.key),"key"in p){q={};for(var w in p)w!=="key"&&(q[w]=p[w])}else q=p;return p=q.ref,{$$typeof:b,type:m,key:nl,ref:p!==void 0?p:null,props:q}}return an.Fragment=A,an.jsx=_,an.jsxs=_,an}var Pr;function mh(){return Pr||(Pr=1,Ts.exports=dh()),Ts.exports}var z=mh(),Es={exports:{}},L={};/**
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var l0;function rh(){if(l0)return L;l0=1;var b=Symbol.for("react.transitional.element"),A=Symbol.for("react.portal"),_=Symbol.for("react.fragment"),m=Symbol.for("react.strict_mode"),p=Symbol.for("react.profiler"),q=Symbol.for("react.consumer"),nl=Symbol.for("react.context"),w=Symbol.for("react.forward_ref"),C=Symbol.for("react.suspense"),ll=Symbol.for("react.memo"),U=Symbol.for("react.lazy"),S=Symbol.for("react.activity"),B=Symbol.for("react.view_transition"),Z=Symbol.iterator;function El(o){return o===null||typeof o!="object"?null:(o=Z&&o[Z]||o["@@iterator"],typeof o=="function"?o:null)}var wl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},vl=Object.assign,kl={};function nt(o,N,j){this.props=o,this.context=N,this.refs=kl,this.updater=j||wl}nt.prototype.isReactComponent={},nt.prototype.setState=function(o,N){if(typeof o!="object"&&typeof o!="function"&&o!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,o,N,"setState")},nt.prototype.forceUpdate=function(o){this.updater.enqueueForceUpdate(this,o,"forceUpdate")};function Ke(){}Ke.prototype=nt.prototype;function Bt(o,N,j){this.props=o,this.context=N,this.refs=kl,this.updater=j||wl}var qt=Bt.prototype=new Ke;qt.constructor=Bt,vl(qt,nt.prototype),qt.isPureReactComponent=!0;var it=Array.isArray;function W(){}var il={H:null,A:null,T:null,S:null},Gt=Object.prototype.hasOwnProperty;function bt(o,N,j){var Y=j.ref;return{$$typeof:b,type:o,key:N,ref:Y!==void 0?Y:null,props:j}}function Tt(o,N){return bt(o.type,N,o.props)}function ct(o){return typeof o=="object"&&o!==null&&o.$$typeof===b}function he(o){var N={"=":"=0",":":"=2"};return"$"+o.replace(/[=:]/g,function(j){return N[j]})}var Je=/\/+/g;function Cl(o,N){return typeof o=="object"&&o!==null&&o.key!=null?he(""+o.key):N.toString(36)}function O(o){switch(o.status){case"fulfilled":return o.value;case"rejected":throw o.reason;default:switch(typeof o.status=="string"?o.then(W,W):(o.status="pending",o.then(function(N){o.status==="pending"&&(o.status="fulfilled",o.value=N)},function(N){o.status==="pending"&&(o.status="rejected",o.reason=N)})),o.status){case"fulfilled":return o.value;case"rejected":throw o.reason}}throw o}function Q(o,N,j,Y,el){var ul=typeof o;(ul==="undefined"||ul==="boolean")&&(o=null);var cl=!1;if(o===null)cl=!0;else switch(ul){case"bigint":case"string":case"number":cl=!0;break;case"object":switch(o.$$typeof){case b:case A:cl=!0;break;case U:return cl=o._init,Q(cl(o._payload),N,j,Y,el)}}if(cl)return el=el(o),cl=Y===""?"."+Cl(o,0):Y,it(el)?(j="",cl!=null&&(j=cl.replace(Je,"$&/")+"/"),Q(el,N,j,"",function(le){return le})):el!=null&&(ct(el)&&(el=Tt(el,j+(el.key==null||o&&o.key===el.key?"":(""+el.key).replace(Je,"$&/")+"/")+cl)),N.push(el)),1;cl=0;var R=Y===""?".":Y+":";if(it(o))for(var V=0;V<o.length;V++)Y=o[V],ul=R+Cl(Y,V),cl+=Q(Y,N,j,ul,el);else if(V=El(o),typeof V=="function")for(o=V.call(o),V=0;!(Y=o.next()).done;)Y=Y.value,ul=R+Cl(Y,V++),cl+=Q(Y,N,j,ul,el);else if(ul==="object"){if(typeof o.then=="function")return Q(O(o),N,j,Y,el);throw N=String(o),Error("Objects are not valid as a React child (found: "+(N==="[object Object]"?"object with keys {"+Object.keys(o).join(", ")+"}":N)+"). If you meant to render a collection of children, use an array instead.")}return cl}function X(o,N,j){if(o==null)return o;var Y=[],el=0;return Q(o,Y,"","",function(ul){return N.call(j,ul,el++)}),Y}function hl(o){if(o._status===-1){var N=o._result,j=N();j.then(function(Y){(o._status===0||o._status===-1)&&(o._status=1,o._result=Y,j.status===void 0&&(j.status="fulfilled",j.value=Y))},function(Y){(o._status===0||o._status===-1)&&(o._status=2,o._result=Y,j.status===void 0&&(j.status="rejected",j.reason=Y))}),o._status===-1&&(o._status=0,o._result=j)}if(o._status===1)return o._result.default;throw o._result}var sl=typeof reportError=="function"?reportError:function(o){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var N=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof o=="object"&&o!==null&&typeof o.message=="string"?String(o.message):String(o),error:o});if(!window.dispatchEvent(N))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",o);return}console.error(o)};function Dt(o){var N=il.T,j={};j.types=N!==null?N.types:null,il.T=j;try{var Y=o(),el=il.S;el!==null&&el(j,Y),typeof Y=="object"&&Y!==null&&typeof Y.then=="function"&&Y.then(W,sl)}catch(ul){sl(ul)}finally{N!==null&&j.types!==null&&(N.types=j.types),il.T=N}}function Pt(o){var N=il.T;if(N!==null){var j=N.types;j===null?N.types=[o]:j.indexOf(o)===-1&&j.push(o)}else Dt(Pt.bind(null,o))}var $e={map:X,forEach:function(o,N,j){X(o,function(){N.apply(this,arguments)},j)},count:function(o){var N=0;return X(o,function(){N++}),N},toArray:function(o){return X(o,function(N){return N})||[]},only:function(o){Line truncated
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var e0;function vh(){return e0||(e0=1,(function(b){function A(O,Q){var X=O.length;O.push(Q);l:for(;0<X;){var hl=X-1>>>1,sl=O[hl];if(0<p(sl,Q))O[hl]=Q,O[X]=sl,X=hl;else break l}}function _(O){return O.length===0?null:O[0]}function m(O){if(O.length===0)return null;var Q=O[0],X=O.pop();if(X!==Q){O[0]=X;l:for(var hl=0,sl=O.length,Dt=sl>>>1;hl<Dt;){var Pt=2*(hl+1)-1,$e=O[Pt],o=Pt+1,N=O[o];if(0>p($e,X))o<sl&&0>p(N,$e)?(O[hl]=N,O[o]=X,hl=o):(O[hl]=$e,O[Pt]=X,hl=Pt);else if(o<sl&&0>p(N,X))O[hl]=N,O[o]=X,hl=o;else break l}}return Q}function p(O,Q){var X=O.sortIndex-Q.sortIndex;return X!==0?X:O.id-Q.id}if(b.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var q=performance;b.unstable_now=function(){return q.now()}}else{var nl=Date,w=nl.now();b.unstable_now=function(){return nl.now()-w}}var C=[],ll=[],U=1,S=null,B=3,Z=!1,El=!1,wl=!1,vl=!1,kl=typeof setTimeout=="function"?setTimeout:null,nt=typeof clearTimeout=="function"?clearTimeout:null,Ke=typeof setImmediate<"u"?setImmediate:null;function Bt(O){for(var Q=_(ll);Q!==null;){if(Q.callback===null)m(ll);else if(Q.startTime<=O)m(ll),Q.sortIndex=Q.expirationTime,A(C,Q);else break;Q=_(ll)}}function qt(O){if(wl=!1,Bt(O),!El)if(_(C)!==null)El=!0,it||(it=!0,ct());else{var Q=_(ll);Q!==null&&Cl(qt,Q.startTime-O)}}var it=!1,W=-1,il=5,Gt=-1;function bt(){return vl?!0:!(b.unstable_now()-Gt<il)}function Tt(){if(vl=!1,it){var O=b.unstable_now();Gt=O;var Q=!0;try{l:{El=!1,wl&&(wl=!1,nt(W),W=-1),Z=!0;var X=B;try{t:{for(Bt(O),S=_(C);S!==null&&!(S.expirationTime>O&&bt());){var hl=S.callback;if(typeof hl=="function"){S.callback=null,B=S.priorityLevel;var sl=hl(S.expirationTime<=O);if(O=b.unstable_now(),typeof sl=="function"){S.callback=sl,Bt(O),Q=!0;break t}S===_(C)&&m(C),Bt(O)}else m(C);S=_(C)}if(S!==null)Q=!0;else{var Dt=_(ll);Dt!==null&&Cl(qt,Dt.startTime-O),Q=!1}}break l}finally{S=null,B=X,Z=!1}Q=void 0}}finally{Q?ct():it=!1}}}var ct;if(typeof Ke=="function")ct=function(){Ke(Tt)};else if(typeof MessageChannel<"u"){var he=new MessageChannel,Je=he.port2;he.port1.onmessage=Tt,ct=function(){Je.postMessage(null)}}else ct=function(){kl(Tt,0)};function Cl(O,Q){W=kl(function(){O(b.unstable_now())},Q)}b.unstable_IdlePriority=5,b.unstable_ImmediatePriority=1,b.unstable_LowPriority=4,b.unstable_NormalPriority=3,b.unstable_Profiling=null,b.unstable_UserBlockingPriority=2,b.unstable_cancelCallback=function(O){O.callback=null},b.unstable_forceFrameRate=function(O){0>O||125<O?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):il=0<O?Math.floor(1e3/O):5},b.unstable_getCurrentPriorityLevel=function(){return B},b.unstable_next=function(O){switch(B){case 1:case 2:case 3:var Q=3;break;default:Q=B}var X=B;B=Q;try{return O()}finally{B=X}},b.unstable_requestPaint=function(){vl=!0},b.unstable_runWithPriority=function(O,Q){switch(O){case 1:case 2:case 3:case 4:case 5:break;default:O=3}var X=B;B=O;try{return Q()}finally{B=X}},b.unstable_scheduleCallback=function(O,Q,X){var hl=b.unstable_now();switch(typeof X=="object"&&X!==null?(X=X.delay,X=typeof X=="number"&&0<X?hl+X:hl):X=hl,O){case 1:var sl=-1;break;case 2:sl=250;break;case 5:sl=1073741823;break;case 4:sl=1e4;break;default:sl=5e3}return sl=X+sl,O={id:U++,callback:Q,priorityLevel:O,startTime:X,expirationTime:sl,sortIndex:-1},X>hl?(O.sortIndex=X,A(ll,O),_(C)===null&&O===_(ll)&&(wl?(nt(W),W=-1):wl=!0,Cl(qt,X-hl))):(O.sortIndex=sl,A(C,O),El||Z||(El=!0,it||(it=!0,ct()))),O},b.unstable_shouldYield=bt,b.unstable_wrapCallback=function(O){var Q=B;return function(){var X=B;B=Q;try{return O.apply(this,arguments)}finally{B=X}}}})(Ns)),Ns}var u0;function yh(){return u0||(u0=1,_s.exports=vh()),_s.exports}var xs={exports:{}},Ll={};/**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var a0;function hh(){if(a0)return Ll;a0=1;var b=Os();function A(U){var S="https://react.dev/errors/"+U;if(1<arguments.length){S+="?args[]="+encodeURIComponent(arguments[1]);for(var B=2;B<arguments.length;B++)S+="&args[]="+encodeURIComponent(arguments[B])}return"Minified React error #"+U+"; visit "+S+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function _(){}var m={d:{f:_,r:function(){throw Error(A(522))},D:_,C:_,L:_,m:_,X:_,S:_,M:_},p:0,findDOMNode:null},p=Symbol.for("react.portal"),q=Symbol.for("react.recoverable"),nl=Symbol.for("react.optimistic_key");function w(U,S,B){var Z=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:p,key:Z==null?null:Z===nl?nl:""+Z,children:U,containerInfo:S,implementation:B}}var C=b.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function ll(U,S){if(U==="font")return"";if(typeof S=="string")return S==="use-credentials"?S:""}return Ll.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=m,Ll.browser=function(U){return{$$typeof:q,_reason:U}},Ll.createPortal=function(U,S){var B=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!S||S.nodeType!==1&&S.nodeType!==9&&S.nodeType!==11)throw Error(A(299));return w(U,S,null,B)},Ll.flushSync=function(U){var S=C.T,B=m.p;try{if(C.T=null,m.p=2,U)return U()}finally{C.T=S,m.p=B,m.d.f()}},Ll.preconnect=function(U,S){typeof U=="string"&&(S?(S=S.crossOrigin,S=typeof S=="string"?S==="use-credentials"?S:"":void 0):S=null,m.d.C(U,S))},Ll.prefetchDNS=function(U){typeof U=="string"&&m.d.D(U)},Ll.preinit=function(U,S){if(typeof U=="string"&&S&&typeof S.as=="string"){var B=S.as,Z=ll(B,S.crossOrigin),El=typeof S.integrity=="string"?S.integrity:void 0,wl=typeof S.fetchPriority=="string"?S.fetchPriority:void 0;B==="style"?m.d.S(U,typeof S.precedence=="string"?S.precedence:void 0,{crossOrigin:Z,integrity:El,fetchPriority:wl}):B==="script"&&m.d.X(U,{crossOrigin:Z,integrity:El,fetchPriority:wl,nonce:typeof S.nonce=="string"?S.nonce:void 0})}},Ll.preinitModule=function(U,S){if(typeof U=="string")if(typeof S=="object"&&S!==null){if(S.as==null||S.as==="script"){var B=ll(S.as,S.crossOrigin);m.d.M(U,{crossOrigin:B,integrity:typeof S.integrity=="string"?S.integrity:void 0,nonce:typeof S.nonce=="string"?S.nonce:void 0,fetchPriority:typeof S.fetchPriority=="string"?S.fetchPriority:void 0})}}else S==null&&m.d.M(U)},Ll.preload=function(U,S){if(typeof U=="string"&&typeof S=="object"&&S!==null&&typeof S.as=="string"){var B=S.as,Z=ll(B,S.crossOrigin);m.d.L(U,B,{crossOrigin:Z,integrity:typeof S.integrity=="string"?S.integrity:void 0,nonce:typeof S.nonce=="string"?S.nonce:void 0,type:typeof S.type=="string"?S.type:void 0,fetchPriority:typeof S.fetchPriority=="string"?S.fetchPriority:void 0,referrerPolicy:typeof S.referrerPolicy=="string"?S.referrerPolicy:void 0,imageSrcSet:typeof S.imageSrcSet=="string"?S.imageSrcSet:void 0,imageSizes:typeof S.imageSizes=="string"?S.imageSizes:void 0,media:typeof S.media=="string"?S.media:void 0})}},Ll.preloadModule=function(U,S){if(typeof U=="string")if(S){var B=ll(S.as,S.crossOrigin);m.d.m(U,{as:typeof S.as=="string"&&S.as!=="script"?S.as:void 0,crossOrigin:B,integrity:typeof S.integrity=="string"?S.integrity:void 0,nonce:typeof S.nonce=="string"?S.nonce:void 0,fetchPriority:typeof S.fetchPriority=="string"?S.fetchPriority:void 0})}else m.d.m(U)},Ll.requestFormReset=function(U){m.d.r(U)},Ll.unstable_batchedUpdates=function(U,S){return U(S)},Ll.useFormState=function(U,S,B){return C.H.useFormState(U,S,B)},Ll.useFormStatus=function(){return C.H.useHostTransitionStatus()},Ll.version="19.3.1",Ll}var n0;function gh(){if(n0)return xs.exports;n0=1;function b(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(b)}catch(A){console.error(A)}}return b(),xs.exports=hh(),xs.exports}/**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var i0;function Sh(){if(i0)return nn;i0=1;var b=yh(),A=Os(),_=gh();function m(l){var t="https://react.dev/errors/"+l;if(1<arguments.length){t+="?args[]="+encodeURIComponent(arguments[1]);for(var e=2;e<arguments.length;e++)t+="&args[]="+encodeURIComponent(arguments[e])}return"Minified React error #"+l+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function p(l){return!(!l||l.nodeType!==1&&l.nodeType!==9&&l.nodeType!==11)}function q(l){for(var t=l,e=t;e&&!e.alternate;)t=e,(t.flags&4098)!==0&&(l=t.return),e=t.return;for(;t.return;)t=t.return;return t.tag===3?l:null}function nl(l){if(l.tag===13){var t=l.memoizedState;if(t===null&&(l=l.alternate,l!==null&&(t=l.memoizedState)),t!==null)return t.dehydrated}return null}function w(l){if(l.tag===31){var t=l.memoizedState;if(t===null&&(l=l.alternate,l!==null&&(t=l.memoizedState)),t!==null)return t.dehydrated}return null}function C(l){if(q(l)!==l)throw Error(m(188))}function ll(l){var t=l.alternate;if(!t){if(t=q(l),t===null)throw Error(m(188));return t!==l?null:l}for(var e=l,u=t;;){var a=e.return;if(a===null)break;var n=a.alternate;if(n===null){if(u=a.return,u!==null){e=u;continue}break}if(a.child===n.child){for(n=a.child;n;){if(n===e)return C(a),l;if(n===u)return C(a),t;n=n.sibling}throw Error(m(188))}if(e.return!==u.return)e=a,u=n;else{for(var i=!1,c=a.child;c;){if(c===e){i=!0,e=a,u=n;break}if(c===u){i=!0,u=a,e=n;break}c=c.sibling}if(!i){for(c=n.child;c;){if(c===e){i=!0,e=n,u=a;break}if(c===u){i=!0,u=n,e=a;break}c=c.sibling}if(!i)throw Error(m(189))}}if(e.alternate!==u)throw Error(m(190))}if(e.tag!==3)throw Error(m(188));return e.stateNode.current===e?l:t}function U(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l;for(l=l.child;l!==null;){if(t=U(l),t!==null)return t;l=l.sibling}return null}function S(l,t,e,u,a,n){for(;l!==null;){if((l.tag===5||l.tag===27||l.tag===6)&&e(l,u,a,n)||(l.tag!==22||l.memoizedState===null)&&(t||l.tag!==5&&l.tag!==27)&&S(l.child,t,e,u,a,n))return!0;l=l.sibling}return!1}function B(l){for(l=l.return;l!==null;){if(l.tag===3||l.tag===5||l.tag===27)return l;l=l.return}return null}function Z(l){var t=!1;for(l=l.return;l!==null&&(l.tag===4&&(t=!0),!(l.tag===3||l.tag===5||l.tag===27));)l=l.return;return t}function El(l){var t=[null,null],e=B(l);return e===null||wl(t,l,e.child,{foundSelf:!1}),t}function wl(l,t,e,u){for(;e!==null;){if(e===t)u.foundSelf=!0;else if(e.tag===5||e.tag===27||e.tag===6){if(u.foundSelf)return l[1]=e,!0;l[0]=e}else if((e.tag!==22||e.memoizedState===null)&&wl(l,t,e.child,u))return!0;e=e.sibling}return!1}function vl(l){switch(l.tag){case 5:case 27:case 6:return l.stateNode;case 3:return l.stateNode.containerInfo;default:throw Error(m(559))}}var kl=null,nt=null;function Ke(l,t,e){return l===e?!0:l===t?(kl=l,!0):!1}function Bt(l,t,e){return l===e?(nt=l,!1):l===t?(nt!==null&&(kl=l),!0):!1}function qt(l){if(l===null)return null;do l=l===null?null:l.return;while(l&&l.tag!==5&&l.tag!==27&&l.tag!==3);return l||null}function it(l,t,e){for(var u=0,a=l;a;a=e(a))u++;a=0;for(var n=t;n;n=e(n))a++;for(;0<u-a;)l=e(l),u--;for(;0<a-u;)t=e(t),a--;for(;u--;){if(l===t||t!==null&&l===t.alternate)return l;l=e(l),t=e(t)}return null}var W=Object.assign,il=Symbol.for("react.element"),Gt=Symbol.for("react.transitional.element"),bt=Symbol.for("react.portal"),Tt=Symbol.for("react.fragment"),ct=Symbol.for("react.strict_mode"),he=Symbol.for("react.profiler"),Je=Symbol.for("react.consumer"),Cl=Symbol.for("react.context"),O=Symbol.for("react.forward_ref"),Q=Symbol.for("react.suspense"),X=Symbol.for("react.suspense_list"),hl=Symbol.for("react.memo"),sl=Symbol.for("react.lazy"),Dt=Symbol.for("react.activity"),Pt=Symbol.for("react.legacy_hidden"),$e=Symbol.for("react.memo_cache_sentinel"),o=Symbol.for("react.view_transition"),N=Symbol.for("react.recoverable"),j=Symbol.iterator;function Y(l){return l===null||typeof l!="object"?null:(l=j&&l[j]||l["@@iterator"],typeof l=="function"?l:null)}var el=Symbol.for("react.client.reference");function ul(l){if(l==null)return null;if(typeof l=="function")return l.$$typeof===el?null:l.displayName||l.name||null;if(typeof l=="string")return l;switch(l){case Tt:return"Fragment";case he:return"Profiler";case ct:return"StrictMode";case Q:return"Suspense";case X:return"SuspenseList";case Dt:return"Activity";case o:return"ViewTransition"}if(typeof l=="object")switch(l.$$typeof){case bt:return"Portal";case Cl:return l.displayName||"Context";case Je:return(l._context.displayName||"Context")+".Consumer";case O:var t=l.render;return l=l.displayName,l||(l=t.displayName||t.name||"",l=l!==""?"ForwardRef("+l+")":"ForwardRef"),l;case hl:return t=l.displayName||null,t!==null?t:ul(l.type)||"Memo";case sl:t=l._payload,l=l._init;try{return ul(l(t))}catch{}}return null}var cl=Array.isArray,R=A.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,V=_.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,le={pending:!1,data:null,method:null,actiLine truncated
|
||||
at`)?" (<anonymous>)":-1<e.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+Qi+l+As}var Xi=!1;function Zi(l,t){if(!l||Xi)return"";Xi=!0;var e=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={DetermineComponentFrameRoot:function(){try{if(t){var E=function(){throw Error()};if(Object.defineProperty(E.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(E,[])}catch(x){var d=x}Reflect.construct(l,[],E)}else{try{E.call()}catch(x){d=x}E=!1;try{var h=Object.getOwnPropertyDescriptor(l.prototype,"props");Object.defineProperty(l.prototype,"props",{configurable:!0,set:function(){throw Error()}}),E=!0,new l}finally{E&&(h!==void 0?Object.defineProperty(l.prototype,"props",h):delete l.prototype.props)}}}else{try{throw Error()}catch(x){d=x}(E=l())&&typeof E.catch=="function"&&E.catch(function(){})}}catch(x){if(x&&d&&typeof x.stack=="string")return[x.stack,d.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var n=u.DetermineComponentFrameRoot(),i=n[0],c=n[1];if(i&&c){var f=i.split(`
|
||||
`),v=c.split(`
|
||||
`);for(a=u=0;u<f.length&&!f[u].includes("DetermineComponentFrameRoot");)u++;for(;a<v.length&&!v[a].includes("DetermineComponentFrameRoot");)a++;if(u===f.length||a===v.length)for(u=f.length-1,a=v.length-1;1<=u&&0<=a&&f[u]!==v[a];)a--;for(;1<=u&&0<=a;u--,a--)if(f[u]!==v[a]){if(u!==1||a!==1)do if(u--,a--,0>a||f[u]!==v[a]){var g=`
|
||||
`+f[u].replace(" at new "," at ");return l.displayName&&g.includes("<anonymous>")&&(g=g.replace("<anonymous>",l.displayName)),g}while(1<=u&&0<=a);break}}}finally{Xi=!1,Error.prepareStackTrace=e}return(e=l?l.displayName||l.name:"")?Se(e):""}function y0(l,t){switch(l.tag){case 26:case 27:case 5:return Se(l.type);case 16:return Se("Lazy");case 13:return l.child!==t&&t!==null?Se("Suspense Fallback"):Se("Suspense");case 19:return Se("SuspenseList");case 0:case 15:return Zi(l.type,!1);case 11:return Zi(l.type.render,!1);case 1:return Zi(l.type,!0);case 31:return Se("Activity");case 30:return Se("ViewTransition");default:return""}}function ps(l){try{var t="",e=null;do t+=y0(l,e),e=l,l=l.return;while(l);return t}catch(u){return`
|
||||
Error generating stack: `+u.message+`
|
||||
`+u.stack}}var Vi=Object.prototype.hasOwnProperty,Li=b.unstable_scheduleCallback,wi=b.unstable_cancelCallback,h0=b.unstable_shouldYield,g0=b.unstable_requestPaint,ft=b.unstable_now,S0=b.unstable_getCurrentPriorityLevel,Ms=b.unstable_ImmediatePriority,Ds=b.unstable_UserBlockingPriority,on=b.unstable_NormalPriority,b0=b.unstable_LowPriority,Us=b.unstable_IdlePriority,T0=b.log,E0=b.unstable_setDisableYieldValue,da=null,st=null;function be(l){if(typeof T0=="function"&&E0(l),st&&typeof st.setStrictMode=="function")try{st.setStrictMode(da,l)}catch{}}var ot=Math.clz32?Math.clz32:N0,z0=Math.log,_0=Math.LN2;function N0(l){return l>>>=0,l===0?32:31-(z0(l)/_0|0)|0}var dn=256,mn=262144,rn=4194304;function We(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&-l;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function vn(l,t,e){var u=l.pendingLanes;if(u===0)return 0;var a=0,n=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var c=u&134217727;return c!==0?(u=c&~n,u!==0?a=We(u):(i&=c,i!==0?a=We(i):e||(e=c&~l,e!==0&&(a=We(e))))):(c=u&~n,c!==0?a=We(c):i!==0?a=We(i):e||(e=u&~l,e!==0&&(a=We(e)))),a===0?0:t!==0&&t!==a&&(t&n)===0&&(n=a&-a,e=t&-t,n>=e||n===32&&(e&4194048)!==0)?t:a}function ma(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function Cs(l,t){(t&8)!==0&&(t|=t&32);var e=l.entangledLanes;if(e!==0)for(l=l.entanglements,e&=t;0<e;){var u=31-ot(e),a=1<<u;t|=l[u],e&=~a}return t}function x0(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Rs(){var l=rn;return rn<<=1,(rn&62914560)===0&&(rn=4194304),l}function Ki(l){for(var t=[],e=0;31>e;e++)t.push(l);return t}function ra(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function O0(l,t,e,u,a,n){var i=l.pendingLanes;l.pendingLanes=e,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=e,l.entangledLanes&=e,l.errorRecoveryDisabledLanes&=e,l.shellSuspendCounter=0;var c=l.entanglements,f=l.expirationTimes,v=l.hiddenUpdates;for(e=i&~e;0<e;){var g=31-ot(e),E=1<<g;c[g]=0,f[g]=-1;var d=v[g];if(d!==null)for(v[g]=null,g=0;g<d.length;g++){var h=d[g];h!==null&&(h.lane&=-536870913)}e&=~E}u!==0&&Hs(l,u,0),n!==0&&a===0&&l.tag!==0&&(l.suspendedLanes|=n&~(i&~t))}function Hs(l,t,e){l.pendingLanes|=t,l.suspendedLanes&=~t;var u=31-ot(t);l.entangledLanes|=t,l.entanglements[u]=l.entanglements[u]|1073741824|e&261930}function js(l,t){var e=l.entangledLanes|=t;for(l=l.entanglements;e;){var u=31-ot(e),a=1<<u;a&t|l[u]&t&&(l[u]|=t),e&=~a}}function Ys(l,t){var e=t&-t;return e=(e&42)!==0?1:Ji(e),(e&(l.suspendedLanes|t))!==0?0:e}function Ji(l){switch(l){case 2:l=1;break;case 8:l=4;break;case 32:l=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:l=128;break;case 268435456:l=134217728;break;default:l=0}return l}function $i(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function Bs(){var l=V.p;return l!==0?l:(l=window.event,l===void 0?32:wr(l.type))}function qs(l,t){var e=V.p;try{return V.p=l,t()}finally{V.p=e}}var te=Math.random().toString(36).slice(2),ql="__reactFiber$"+te,Il="__reactProps$"+te,Tu="__reactContainer$"+te,Gs="__reactEvents$"+te,A0="__reactListeners$"+te,p0="__reactHandles$"+te,Qs="__reactResources$"+te,va="__reactMarker$"+te,yn="__reactLoad$"+te;function hn(l){delete l[ql],delete l[Il],delete l[A0],delete l[p0]}function Fe(l){var t;if(t=l[ql])return t;for(var e=l.parentNode;e;){if(t=e[Tu]||e[ql]){if(e=t.alternate,t.child!==null||e!==null&&e.child!==null)for(l=xr(l);l!==null;){if(e=l[ql])return e;l=xr(l)}return t}l=e,e=l.parentNode}return null}function Eu(l){if(l=l[ql]||l[Tu]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function ya(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(m(33))}function zu(l){var t=l[Qs];return t||(t=l[Qs]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function Rl(l){l[va]=!0}function Xs(l){l[yn]=void 0}var Zs=new Set,Vs={};function ke(l,t){_u(l,t),_u(l+"Capture",t)}function
Line truncated
|
||||
`).replace(yy,"")}function nr(l,t){return t=ar(t),ar(l)===t}function rl(l,t,e,u,a,n){switch(e){case"children":if(typeof u=="string")t==="body"||t==="textarea"&&u===""||xu(l,u);else if(typeof u=="number"||typeof u=="bigint")t!=="body"&&xu(l,""+u);else return;break;case"className":Sn(l,"class",u);break;case"tabIndex":Sn(l,"tabindex",u);break;case"dir":case"role":case"viewBox":case"width":case"height":Sn(l,e,u);break;case"style":Ps(l,u,n);return;case"data":if(t!=="object"){Sn(l,"data",u);break}case"src":case"href":if(u===""&&(t!=="a"||e!=="href")){l.removeAttribute(e);break}if(u==null||typeof u=="function"||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(e);break}u=bn(u),l.setAttribute(e,u);break;case"action":case"formAction":if(typeof u=="function"){l.setAttribute(e,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof n=="function"&&(e==="formAction"?(t!=="input"&&rl(l,t,"name",a.name,a,null),rl(l,t,"formEncType",a.formEncType,a,null),rl(l,t,"formMethod",a.formMethod,a,null),rl(l,t,"formTarget",a.formTarget,a,null)):(rl(l,t,"encType",a.encType,a,null),rl(l,t,"method",a.method,a,null),rl(l,t,"target",a.target,a,null)));if(u==null||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(e);break}u=bn(u),l.setAttribute(e,u);break;case"onClick":u!=null&&(l.onclick=Zt);return;case"onScroll":u!=null&&k("scroll",l);return;case"onScrollEnd":u!=null&&k("scrollend",l);return;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(m(61));if(e=u.__html,e!=null){if(a.children!=null)throw Error(m(60));(n!=null?n.__html:void 0)!==e&&(l.innerHTML=e)}}break;case"multiple":l.multiple=u&&typeof u!="function"&&typeof u!="symbol";break;case"muted":l.muted=u&&typeof u!="function"&&typeof u!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(u==null||typeof u=="function"||typeof u=="boolean"||typeof u=="symbol"){l.removeAttribute("xlink:href");break}e=bn(u),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",e);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,u):l.removeAttribute(e);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":u&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,""):l.removeAttribute(e);break;case"capture":case"download":u===!0?l.setAttribute(e,""):u!==!1&&u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,u):l.removeAttribute(e);break;case"cols":case"rows":case"size":case"span":u!=null&&typeof u!="function"&&typeof u!="symbol"&&!isNaN(u)&&1<=u?l.setAttribute(e,u):l.removeAttribute(e);break;case"rowSpan":case"start":u==null||typeof u=="function"||typeof u=="symbol"||isNaN(u)?l.removeAttribute(e):l.setAttribute(e,u);break;case"popover":k("beforetoggle",l),k("toggle",l),gn(l,"popover",u);break;case"xlinkActuate":ee(l,"http://www.w3.org/1999/xlink","xlink:actuate",u);break;case"xlinkArcrole":ee(l,"http://www.w3.org/1999/xlink","xlink:arcrole",u);break;case"xlinkRole":ee(l,"http://www.w3.org/1999/xlink","xlink:role",u);break;case"xlinkShow":ee(l,"http://www.w3.org/1999/xlink","xlink:show",u);break;case"xlinkTitle":ee(l,"http://www.w3.org/1999/xlink","xlink:title",u);break;case"xlinkType":ee(l,"http://www.w3.org/1999/xlink","xlink:type",u);break;case"xmlBase":ee(l,"http://www.w3.org/XML/1998/namespace","xml:base",u);break;case"xmlLang":ee(l,"http://www.w3.org/XML/1998/namespace","xml:lang",u);break;case"xmlSpace":ee(l,"http://www.w3.org/XML/1998/namespace","xml:space",u);break;case"is":gn(l,"is",u);break;case"innerText":case"textContent":return;default:if(!(2<e.length)||e[0]!=="o"&&e[0]!=="O"||e[1]!=="n"&&e[1]!=="N")e=H0.get(e)||e,gn(l,e,u);else return}al=!0}function kf(l,t,e,u,a,n){switch(e){case"style":Ps(l,u,n);return;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(m(61));if(e=u.__html,e!=null){if(a.children!=null)throw Error(m(60));(n!=null?n.__html:void 0)!==e&&(l.innerHTML=e)}}break;case"children":if(typeof u=="string")xu(l,u);else if(typeof u=="number"||typeof u=="bigint")xu(l,""+u);else return;break;case"onScroll":u!=null&&k("scroll",l);return;case"onScrollEnd":u!=null&&k("scrollend"
Line truncated
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Ah=b=>b.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),m0=(...b)=>b.filter((A,_,m)=>!!A&&A.trim()!==""&&m.indexOf(A)===_).join(" ").trim();/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/var ph={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Mh=at.forwardRef(({color:b="currentColor",size:A=24,strokeWidth:_=2,absoluteStrokeWidth:m,className:p="",children:q,iconNode:nl,...w},C)=>at.createElement("svg",{ref:C,...ph,width:A,height:A,stroke:b,strokeWidth:m?Number(_)*24/Number(A):_,className:m0("lucide",p),...w},[...nl.map(([ll,U])=>at.createElement(ll,U)),...Array.isArray(q)?q:[q]]));/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const r0=(b,A)=>{const _=at.forwardRef(({className:m,...p},q)=>at.createElement(Mh,{ref:q,iconNode:A,className:m0(`lucide-${Ah(b)}`,m),...p}));return _.displayName=`${b}`,_};/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Dh=[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]],v0=r0("Search",Dh);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Uh=[["polyline",{points:"4 17 10 11 4 5",key:"akl6gq"}],["line",{x1:"12",x2:"20",y1:"19",y2:"19",key:"q2wloq"}]],Ch=r0("Terminal",Uh),Rh=[{id:"zinc-dark",label:"深色极客",dot:"#18181b"},{id:"zinc-light",label:"浅色纯净",dot:"#f8fafc"},{id:"catppuccin",label:"Catppuccin",dot:"#cba6f7"},{id:"nord",label:"Nord",dot:"#88c0d0"}],Hh=[{id:"auto",label:"智能路由"},{id:"edge443",label:"443 免端口"},{id:"home10443",label:"10443 家宽"},{id:"lan",label:"LAN 局域网"}],jh=()=>{const{theme:b,routeMode:A,telemetry:_,setTheme:m,setRouteMode:p,setShowAgyModal:q,setShowCmdPalette:nl}=sa();return z.jsx("header",{className:"border-b hl-border sticky top-0 z-30 backdrop-blur-md",style:{backgroundColor:"color-mix(in srgb, var(--bg-page) 86%, transparent)"},children:z.jsxs("div",{className:"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 flex flex-wrap items-center justify-between gap-3",children:[z.jsxs("div",{className:"flex flex-wrap items-center gap-2.5 sm:gap-3",children:[z.jsxs("div",{className:"flex items-center gap-2.5 pr-1",children:[z.jsx("img",{src:"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png",alt:"Mac mini",className:"w-6 h-6 rounded"}),z.jsx("span",{className:"font-semibold text-sm tracking-tight",children:"HomeLab"}),z.jsx("span",{className:"w-2 h-2 rounded-full",style:{backgroundColor:"var(--success)"},title:"全屋 4 节点在线"})]}),z.jsxs("div",{className:"hidden xl:flex items-center gap-2 text-xs font-mono-num",children:[z.jsxs("div",{className:"hl-pill px-2.5 py-1 flex items-center gap-2",title:"Apple M6 12核处理器负载",children:[z.jsx("span",{style:{color:"var(--text-muted)"},children:"M6 CPU"}),z.jsx("div",{className:"w-12 h-1.5 rounded-full hl-muted overflow-hidden",children:z.jsx("div",{className:"h-full hl-bar-fill",style:{width:`${Math.max(5,_.mini.cpu_pct)}%`}})}),z.jsxs("span",{className:"font-medium",children:[_.mini.cpu_pct,"%"]})]}),z.jsxs("div",{className:"hl-pill px-2.5 py-1 flex items-center gap-2",title:"Mac mini 24GB 统一内存",children:[z.jsx("span",{style:{color:"var(--text-muted)"},children:"RAM"}),z.jsx("div",{className:"w-12 h-1.5 rounded-full hl-muted overflow-hidden",children:z.jsx("div",{className:"h-full hl-bar-fill",style:{width:`${Math.max(5,_.mini.mem_pct)}%`}})}),z.jsxs("span",{className:"font-medium",children:[_.mini.mem_used_gb,"G"]})]}),z.jsxs("div",{className:"hl-pill px-2.5 py-1 flex items-center gap-2",title:"极空间 Z4 20TB 存储阵列",children:[z.jsx("span",{style:{color:"var(--text-muted)"},children:"NAS 余量"}),z.jsx("div",{className:"w-12 h-1.5 rounded-full hl-muted overflow-hidden",children:z.jsx("div",{className:"h-full hl-bar-fill-green",style:{width:`${Math.max(5,_.nas.pct)}%`}})}),z.jsxs("span",{className:"font-medium",style:{color:"var(--success)"},children:[_.nas.free_tb," TB"]})]}),z.jsxs("div",{className:"hl-pill px-2.5 py-1 flex items-center gap-1.5",title:"AGY 锁定新加坡 Azure 专线出口",children:[z.jsx("span",{className:"w-1.5 h-1.5 rounded-full",style:{backgroundColor:"var(--success)"}}),z.jsx("span",{style:{color:"var(--text-muted)"},children:"SG 专线"}),z.jsx("span",{className:"font-medium",children:_.network.sg_delay_ms>0?`${_.network.sg_delay_ms}ms`:"直连"})]})]})]}),z.jsxs("div",{className:"flex items-center gap-2",children:[z.jsxs("button",{onClick:()=>nl(!0),className:"hl-btn-outline px-2.5 py-1 text-xs flex items-center gap-3 cursor-pointer",title:"按 Cmd+K 唤起全局命令面板",children:[z.jsxs("span",{className:"flex items-center gap-1.5",style:{color:"var(--text-muted)"},children:[z.jsx(v0,{className:"w-3.5 h-3.5"}),z.jsx("span",{className:"hidden sm:inline",children:"搜索或执行指令..."})]}),z.jsx("kbd",{className:"px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted",style:{color:"var(--text-muted)"},children:"⌘K"})]}),z.jsx("div",{className:"hidden md:flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs",children:Hh.map(w=>z.jsx("button",{onClick:()=>p(w.id),className:"px-2 py-1 rounded-md font-medium transition-all cursor-pointer",style:A===w.id?{backgroundColor:"var(--bg-card)",color:"var(--text-main)",boxShadow:"0 1px 2px rgba(0,0,0,0.08)"}:{color:"var(--text-muted)"},children:w.label},w.id))}),z.jsx("div",{className:"flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs",children:Rh.map(w=>z.jsxs("button",{onClick:()=>m(w.id),className:"px-2 py-1 rounded-md font-medium transition-all flex items-center gap-1.5 cursor-pointer",style:b===w.id?{backgroundColor:"var(--bg-card)",color:"var(--text-main)",boxShadow:"0 1px 2px rgba(0,0,0,0.08)"}:{color:"var(--text-muted)"},title:w.label,children:[z.jsx("span",{className:"w-2 h-2 rounded-full border border-black/10",style:{backgroundColor:w.dot}}),z.jsx("span",{className:"hidden lg:inline",children:w.label})]},w.id))}),z.jsxs("button",{onClick:()=>q(!0),className:"hl-btn-primary px-3 py-1.5 text-xs flex items-center gap-1.5 cursor-pointer",children:[z.jsx(Ch,{className:"w-3.5 Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
/*! tailwindcss v4.3.5 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:block))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-black:#000;--spacing:.25rem;--container-xl:36rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-fielLine truncated
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HomeLab Console · 家庭云边中控台</title>
|
||||
<link rel="icon" href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script type="module" crossorigin src="/assets/index-DCqVyTsd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DWXFEfty.css">
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
+21
-3
@@ -11,12 +11,14 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import mimetypes
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
PORT = int(os.environ.get("PORT", "3004"))
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DIST_DIR = BASE_DIR / "dist"
|
||||
INDEX_FILE = BASE_DIR / "index.html"
|
||||
|
||||
_cache = {"data": None, "ts": 0.0}
|
||||
@@ -285,9 +287,25 @@ class PortalHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(collect_telemetry())
|
||||
return
|
||||
|
||||
# Serve SPA index.html for root and any path
|
||||
if INDEX_FILE.exists():
|
||||
body = INDEX_FILE.read_bytes()
|
||||
# Serve static assets from Vite build output (dist/) if available
|
||||
clean_path = self.path.split("?", 1)[0].lstrip("/")
|
||||
if clean_path and DIST_DIR.exists():
|
||||
asset_file = (DIST_DIR / clean_path).resolve()
|
||||
if str(asset_file).startswith(str(DIST_DIR.resolve())) and asset_file.is_file():
|
||||
body = asset_file.read_bytes()
|
||||
mime, _ = mimetypes.guess_type(str(asset_file))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mime or "application/octet-stream")
|
||||
self.send_header("Cache-Control", "public, max-age=86400")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
|
||||
# Serve SPA index.html from dist/index.html (React+Vite build) or fallback index.html
|
||||
spa_index = (DIST_DIR / "index.html") if (DIST_DIR / "index.html").exists() else INDEX_FILE
|
||||
if spa_index.exists():
|
||||
body = spa_index.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HomeLab Console · 家庭云边中控台</title>
|
||||
<link rel="icon" href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2635
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "homelab-console",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { usePortalStore } from './store/usePortalStore';
|
||||
import { TopRibbon } from './components/TopRibbon';
|
||||
import { ServiceCard } from './components/ServiceCard';
|
||||
import { SshShelf } from './components/SshShelf';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { AgyTerminalModal } from './components/AgyTerminalModal';
|
||||
import type { ServiceGroup } from './types/telemetry';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const {
|
||||
telemetry,
|
||||
toast,
|
||||
fetchTelemetry,
|
||||
setShowCmdPalette,
|
||||
getEffectiveMode,
|
||||
routeMode,
|
||||
} = usePortalStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTelemetry();
|
||||
const timer = setInterval(fetchTelemetry, 3000);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setShowCmdPalette(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [fetchTelemetry, setShowCmdPalette]);
|
||||
|
||||
const activeModeLabel = useMemo(() => {
|
||||
const m = getEffectiveMode();
|
||||
if (m === 'lan') return 'LAN 局域网极速直连 (192.168.31.x)';
|
||||
if (m === 'home10443') return '10443 家宽公网 HTTPS 直连';
|
||||
return '443 免端口 HTTPS 直达 (*.961504.xyz)';
|
||||
}, [getEffectiveMode, routeMode]);
|
||||
|
||||
const serviceGroups = useMemo<ServiceGroup[]>(() => {
|
||||
const t = telemetry;
|
||||
return [
|
||||
{
|
||||
title: '核心工作台与私有云存储',
|
||||
subtitle: 'Core Workbench & Storage',
|
||||
showRouteHint: true,
|
||||
items: [
|
||||
{
|
||||
id: 'agy',
|
||||
title: 'AGY 云端工作台',
|
||||
subtitle: 'agy.961504.xyz · ttyd + tmux',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png',
|
||||
online: t.services.agy,
|
||||
actionType: 'drawer',
|
||||
desc: 'M6 Mac mini 上的 24 小时常驻 AI 编程终端,已同步 Google 登录态并锁定新加坡出口,关掉网页后台继续运行。',
|
||||
edgeUrl: 'https://agy.961504.xyz',
|
||||
homeUrl: 'https://agy.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.164:7681',
|
||||
stats: [
|
||||
{ label: 'M6 CPU 负载', value: `${t.mini.cpu_pct}%` },
|
||||
{ label: '已用内存', value: `${t.mini.mem_used_gb} / 24 GB` },
|
||||
{
|
||||
label: 'tmux 会话',
|
||||
value: t.mini.agy_tmux ? '常驻运行中' : '待机',
|
||||
highlight: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'zspace',
|
||||
title: '极空间 Z4 私有云',
|
||||
subtitle: '192.168.31.185 · 20TB RAID 存储池',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/synology-drive.png',
|
||||
online: t.services.nas,
|
||||
desc: '家庭 20TB 大容量存储中心、极影视蓝光影院与手机相册备份,已自动 SMB 挂载至 Mac mini ~/nas。',
|
||||
edgeUrl: 'https://z4.home.961504.xyz:10443',
|
||||
homeUrl: 'https://z4.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.185:5055',
|
||||
stats: [
|
||||
{ label: '剩余可用', value: `${t.nas.free_tb} TB`, highlight: true },
|
||||
{ label: '已用容量', value: `${t.nas.used_tb} TB (${t.nas.pct}%)` },
|
||||
{ label: 'SMB 挂载', value: t.nas.mounted ? '~/nas 已挂载' : '重连中' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vaultwarden',
|
||||
title: 'Vaultwarden 密码保险箱',
|
||||
subtitle: 'vw.961504.xyz · Bitwarden 兼容',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/vaultwarden.png',
|
||||
online: t.services.vaultwarden,
|
||||
actionType: 'backup',
|
||||
desc: '自托管全平台加密密码库,支持 iOS / Android / 浏览器插件自动填充,每天凌晨 03:30 自动打包灾备至极空间。',
|
||||
edgeUrl: 'https://vw.961504.xyz',
|
||||
homeUrl: 'https://vw.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.164:8222',
|
||||
stats: [
|
||||
{ label: '容器状态', value: 'Healthy', highlight: true },
|
||||
{ label: '最近极空间灾备', value: t.nas.latest_backup },
|
||||
{ label: 'Mac SSD 占用', value: `${t.mini.disk_pct}%` },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '网络网关、AI 专线与外部哨兵',
|
||||
subtitle: 'Network Gateway & Cloud Edge',
|
||||
showRouteHint: false,
|
||||
items: [
|
||||
{
|
||||
id: 'clash',
|
||||
title: 'Mihomo 路由分流面板',
|
||||
subtitle: '192.168.31.1:9999 · ShellCrash',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/clash.png',
|
||||
online: t.services.clash,
|
||||
desc: '小米主路由器透明网关控制台,实时监控全屋设备流量、分流规则与狮城 AI 算力专线锁定状态。',
|
||||
edgeUrl: 'https://clash.home.961504.xyz:10443/ui/',
|
||||
homeUrl: 'https://clash.home.961504.xyz:10443/ui/',
|
||||
lanUrl: 'http://192.168.31.1:9999/ui/',
|
||||
stats: [
|
||||
{ label: 'AI 锁定策略', value: '狮城节点', highlight: true },
|
||||
{
|
||||
label: '新加坡延迟',
|
||||
value: t.network.sg_delay_ms > 0 ? `${t.network.sg_delay_ms} ms` : '直连',
|
||||
},
|
||||
{ label: '固定出口 IP', value: t.network.sg_egress_ip },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'router',
|
||||
title: '小米主路由器后台',
|
||||
subtitle: '192.168.31.1 · PPPoE 公网网关',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/openwrt.png',
|
||||
online: t.services.router,
|
||||
desc: '管理家庭联通公网宽带拨号、DHCP 静态 IP 绑定、10443 与 41641 端口转发及全屋内网 Split-DNS。',
|
||||
edgeUrl: 'https://router.home.961504.xyz:10443',
|
||||
homeUrl: 'https://router.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.1',
|
||||
stats: [
|
||||
{ label: '联通公网 IPv4', value: t.network.home_wan_ip, highlight: true },
|
||||
{ label: '泛域名 DDNS', value: '*.home.961504.xyz' },
|
||||
{ label: '内网网关', value: '192.168.31.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uptime',
|
||||
title: 'Uptime Kuma 外部哨兵',
|
||||
subtitle: 'status.961504.xyz · SG Azure',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png',
|
||||
online: t.services.uptime,
|
||||
desc: '独立部署在新加坡 Azure 公有云上的 24 小时外部监控哨兵,通过 Tailscale P2P 专线实时探测家庭服务。',
|
||||
edgeUrl: 'https://status.961504.xyz',
|
||||
homeUrl: 'https://status.961504.xyz',
|
||||
lanUrl: 'https://status.961504.xyz',
|
||||
stats: [
|
||||
{ label: '云端公网 IP', value: '104.43.105.243' },
|
||||
{ label: 'Tailscale 专线', value: '100.115.204.51' },
|
||||
{ label: '外部探测状态', value: '100% 在线', highlight: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [telemetry]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-5 right-5 z-50 hl-card px-4 py-3 flex items-center gap-2.5 shadow-2xl">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--success)' }} />
|
||||
<span className="text-xs font-medium">{toast}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Top Utility & System Resources Ribbon */}
|
||||
<TopRibbon />
|
||||
|
||||
{/* 2. Main Categorized 3-Column Rows */}
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-7">
|
||||
{serviceGroups.map((group) => (
|
||||
<section key={group.title} className="space-y-3">
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{group.title}
|
||||
</h2>
|
||||
<span
|
||||
className="text-[11px] font-mono-num"
|
||||
style={{ color: 'var(--text-subtle)' }}
|
||||
>
|
||||
· {group.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
{group.showRouteHint && (
|
||||
<span
|
||||
className="text-[11px] font-mono-num hidden sm:inline"
|
||||
style={{ color: 'var(--text-subtle)' }}
|
||||
>
|
||||
当前路由模式: {activeModeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{group.items.map((card) => (
|
||||
<ServiceCard key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* 3. Bottom SSH Quick Copy & Maintenance Shelf */}
|
||||
<SshShelf />
|
||||
</main>
|
||||
|
||||
{/* 4. Global Cmd+K Command Palette */}
|
||||
<CommandPalette />
|
||||
|
||||
{/* 5. Embedded AGY Cloud Terminal Modal */}
|
||||
<AgyTerminalModal />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
|
||||
export const AgyTerminalModal: React.FC = () => {
|
||||
const { showAgyModal, setShowAgyModal, getEffectiveMode } = usePortalStore();
|
||||
|
||||
if (!showAgyModal) return null;
|
||||
|
||||
const mode = getEffectiveMode();
|
||||
const url =
|
||||
mode === 'lan'
|
||||
? 'http://192.168.31.164:7681'
|
||||
: mode === 'home10443'
|
||||
? 'https://agy.home.961504.xyz:10443'
|
||||
: 'https://agy.961504.xyz';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-3 sm:p-6">
|
||||
<div className="w-full h-full max-w-6xl hl-card flex flex-col overflow-hidden shadow-2xl">
|
||||
<div className="px-4 py-2.5 border-b hl-border flex items-center justify-between hl-muted">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img
|
||||
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png"
|
||||
alt="AGY"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-xs font-mono-num font-medium">
|
||||
AGY 云端工作台 · tmux 持久会话 (关窗不断连)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hl-btn-outline px-2.5 py-1 text-xs font-mono-num"
|
||||
>
|
||||
新标签页全屏 ↗
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setShowAgyModal(false)}
|
||||
className="hl-btn-primary px-3 py-1 text-xs cursor-pointer"
|
||||
>
|
||||
收起终端
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe src={url} title="AGY Cloud Workbench" className="w-full flex-1 bg-black border-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
|
||||
interface CmdItem {
|
||||
id: string;
|
||||
title: string;
|
||||
hint: string;
|
||||
type: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export const CommandPalette: React.FC = () => {
|
||||
const {
|
||||
showCmdPalette,
|
||||
setShowCmdPalette,
|
||||
setShowAgyModal,
|
||||
setTheme,
|
||||
triggerAction,
|
||||
copyCmd,
|
||||
getEffectiveMode,
|
||||
} = usePortalStore();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCmdPalette) {
|
||||
setQuery('');
|
||||
setTimeout(() => inputRef.current?.focus(), 20);
|
||||
}
|
||||
}, [showCmdPalette]);
|
||||
|
||||
const commands = useMemo<CmdItem[]>(() => {
|
||||
const mode = getEffectiveMode();
|
||||
const resolve = (edge: string, home: string, lan: string) =>
|
||||
mode === 'lan' ? lan : mode === 'home10443' ? home : edge;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'open-agy-drawer',
|
||||
title: '在页内唤起 AGY 云端终端',
|
||||
hint: 'tmux attach -t agy',
|
||||
type: '终端',
|
||||
run: () => setShowAgyModal(true),
|
||||
},
|
||||
{
|
||||
id: 'open-agy',
|
||||
title: '新窗口打开 AGY 云端工作台',
|
||||
hint: 'https://agy.961504.xyz',
|
||||
type: '跳转',
|
||||
run: () =>
|
||||
window.open(
|
||||
resolve(
|
||||
'https://agy.961504.xyz',
|
||||
'https://agy.home.961504.xyz:10443',
|
||||
'http://192.168.31.164:7681'
|
||||
),
|
||||
'_blank'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'open-z4',
|
||||
title: '打开 极空间 Z4 私有云 (20TB)',
|
||||
hint: '192.168.31.185:5055',
|
||||
type: '跳转',
|
||||
run: () =>
|
||||
window.open(
|
||||
resolve(
|
||||
'https://z4.home.961504.xyz:10443',
|
||||
'https://z4.home.961504.xyz:10443',
|
||||
'http://192.168.31.185:5055'
|
||||
),
|
||||
'_blank'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'open-vw',
|
||||
title: '打开 Vaultwarden 密码保险箱',
|
||||
hint: 'https://vw.961504.xyz',
|
||||
type: '跳转',
|
||||
run: () =>
|
||||
window.open(
|
||||
resolve(
|
||||
'https://vw.961504.xyz',
|
||||
'https://vw.home.961504.xyz:10443',
|
||||
'http://192.168.31.164:8222'
|
||||
),
|
||||
'_blank'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'open-clash',
|
||||
title: '打开 Mihomo 路由分流面板',
|
||||
hint: '192.168.31.1:9999',
|
||||
type: '跳转',
|
||||
run: () =>
|
||||
window.open(
|
||||
resolve(
|
||||
'https://clash.home.961504.xyz:10443/ui/',
|
||||
'https://clash.home.961504.xyz:10443/ui/',
|
||||
'http://192.168.31.1:9999/ui/'
|
||||
),
|
||||
'_blank'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'act-backup',
|
||||
title: '立即打包备份核心数据至极空间 Z4',
|
||||
hint: 'backup-to-nas.sh',
|
||||
type: '运维',
|
||||
run: () => triggerAction('backup_nas', '正在打包备份至极空间 Z4...'),
|
||||
},
|
||||
{
|
||||
id: 'act-ddns',
|
||||
title: '立即刷新 Cloudflare DDNS 解析',
|
||||
hint: 'cloudflare-ddns.sh',
|
||||
type: '运维',
|
||||
run: () => triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...'),
|
||||
},
|
||||
{
|
||||
id: 'ssh-mini',
|
||||
title: '复制 SSH 连接 Mac mini 指令',
|
||||
hint: 'ssh mini',
|
||||
type: 'SSH',
|
||||
run: () => copyCmd('ssh mini', 'SSH Mac mini'),
|
||||
},
|
||||
{
|
||||
id: 'ssh-agy',
|
||||
title: '复制 终端接力 AGY 会话指令',
|
||||
hint: 'ssh mini -t "tmux attach -t agy"',
|
||||
type: 'SSH',
|
||||
run: () => copyCmd('ssh mini -t "tmux attach -t agy"', '接力 AGY'),
|
||||
},
|
||||
{
|
||||
id: 'theme-light',
|
||||
title: '切换主题: 浅色纯净 (Shadcn Light)',
|
||||
hint: 'zinc-light',
|
||||
type: '主题',
|
||||
run: () => setTheme('zinc-light'),
|
||||
},
|
||||
{
|
||||
id: 'theme-dark',
|
||||
title: '切换主题: 深色极客 (Shadcn Dark)',
|
||||
hint: 'zinc-dark',
|
||||
type: '主题',
|
||||
run: () => setTheme('zinc-dark'),
|
||||
},
|
||||
{
|
||||
id: 'theme-cat',
|
||||
title: '切换主题: Catppuccin Mocha',
|
||||
hint: 'catppuccin',
|
||||
type: '主题',
|
||||
run: () => setTheme('catppuccin'),
|
||||
},
|
||||
{
|
||||
id: 'theme-nord',
|
||||
title: '切换主题: Nord 极地霜蓝',
|
||||
hint: 'nord',
|
||||
type: '主题',
|
||||
run: () => setTheme('nord'),
|
||||
},
|
||||
];
|
||||
}, [getEffectiveMode, setShowAgyModal, setTheme, triggerAction, copyCmd]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return commands;
|
||||
return commands.filter(
|
||||
(c) => c.title.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q)
|
||||
);
|
||||
}, [query, commands]);
|
||||
|
||||
if (!showCmdPalette) return null;
|
||||
|
||||
const execute = (cmd: CmdItem) => {
|
||||
setShowCmdPalette(false);
|
||||
cmd.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setShowCmdPalette(false);
|
||||
}}
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start justify-center pt-20 px-4"
|
||||
>
|
||||
<div className="w-full max-w-xl hl-card overflow-hidden shadow-2xl">
|
||||
<div className="px-4 py-3 border-b hl-border flex items-center gap-3">
|
||||
<Search className="w-4 h-4" style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
type="text"
|
||||
placeholder="输入服务名称、SSH 指令、路由模式或主题..."
|
||||
className="w-full bg-transparent text-sm outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setShowCmdPalette(false);
|
||||
if (e.key === 'Enter' && filtered.length > 0) execute(filtered[0]);
|
||||
}}
|
||||
/>
|
||||
<kbd
|
||||
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto p-2 text-xs">
|
||||
<div
|
||||
className="px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--text-subtle)' }}
|
||||
>
|
||||
快速操作与服务直达
|
||||
</div>
|
||||
{filtered.map((cmd) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={() => execute(cmd)}
|
||||
className="w-full px-3 py-2 rounded-lg text-left flex items-center justify-between transition-colors cursor-pointer"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--bg-muted)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-medium">{cmd.title}</span>
|
||||
<span className="font-mono-num text-[11px]" style={{ color: 'var(--text-muted)' }}>
|
||||
{cmd.hint}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-mono-num px-1.5 py-0.5 rounded hl-muted"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{cmd.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
import type { ServiceCardItem } from '../types/telemetry';
|
||||
|
||||
interface Props {
|
||||
card: ServiceCardItem;
|
||||
}
|
||||
|
||||
export const ServiceCard: React.FC<Props> = ({ card }) => {
|
||||
const { getEffectiveMode, setShowAgyModal, triggerAction } = usePortalStore();
|
||||
const mode = getEffectiveMode();
|
||||
|
||||
const resolvedUrl =
|
||||
mode === 'lan' ? card.lanUrl : mode === 'home10443' ? card.homeUrl : card.edgeUrl;
|
||||
|
||||
const badgeLabel =
|
||||
mode === 'lan' ? 'LAN 直连' : mode === 'home10443' ? '10443' : '443 免端口';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={resolvedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hl-card hl-card-interactive flex flex-col justify-between overflow-hidden group cursor-pointer"
|
||||
>
|
||||
{/* Card Top: Authentic Brand Icon + Title + Status Dot + Description */}
|
||||
<div className="p-4 sm:p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="w-10 h-10 rounded-xl hl-muted border hl-border p-1.5 flex items-center justify-center shrink-0">
|
||||
<img src={card.iconUrl} alt={card.title} className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold truncate group-hover:underline">
|
||||
{card.title}
|
||||
</h3>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: card.online !== false ? 'var(--success)' : '#ef4444',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className="text-[11px] font-mono-num truncate mt-0.5"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{card.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Pill or Route Badge */}
|
||||
{card.actionType === 'drawer' ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowAgyModal(true);
|
||||
}}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-medium shrink-0 transition-opacity hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-soft)',
|
||||
color: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
页内终端
|
||||
</button>
|
||||
) : card.actionType === 'backup' ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
triggerAction('backup_nas', '正在打包备份至极空间 Z4...');
|
||||
}}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-medium shrink-0 transition-opacity hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--success-soft)',
|
||||
color: 'var(--success)',
|
||||
}}
|
||||
>
|
||||
立即备份
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="text-xs font-mono-num px-2 py-0.5 rounded hl-muted shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-xs leading-relaxed line-clamp-2"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{card.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card Footer: gethomepage.dev Signature 3-Cell Inline Telemetry Widget */}
|
||||
<div className="hl-widget-footer grid grid-cols-3 divide-x hl-border text-center">
|
||||
{card.stats.map((stat, idx) => (
|
||||
<div key={idx} className="py-2.5 px-2 flex flex-col items-center justify-center">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider truncate max-w-full"
|
||||
style={{ color: 'var(--text-subtle)' }}
|
||||
>
|
||||
{stat.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-semibold font-mono-num mt-0.5 truncate max-w-full"
|
||||
style={{
|
||||
color: stat.highlight ? 'var(--success)' : 'var(--text-main)',
|
||||
}}
|
||||
>
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
|
||||
const SSH_LIST = [
|
||||
{ label: 'SSH 连接 Mac mini', cmd: 'ssh mini' },
|
||||
{ label: '终端接力 AGY 会话', cmd: 'ssh mini -t "tmux attach -t agy"' },
|
||||
{ label: 'SSH 连接新加坡 Azure', cmd: 'ssh azure' },
|
||||
{ label: 'SSH 连接小米主路由', cmd: 'ssh root@192.168.31.1' },
|
||||
];
|
||||
|
||||
export const SshShelf: React.FC = () => {
|
||||
const { copyCmd, triggerAction } = usePortalStore();
|
||||
|
||||
return (
|
||||
<section className="space-y-3 pt-1">
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
终端快捷指令与备用直连书签
|
||||
</h2>
|
||||
<span className="text-[11px] font-mono-num" style={{ color: 'var(--text-subtle)' }}>
|
||||
· 点击指令即复制
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<button
|
||||
onClick={() => triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...')}
|
||||
className="hover:underline font-medium cursor-pointer"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
刷新 DDNS 解析
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerAction('mount_nas', '正在巡检极空间 20TB SMB 挂载...')}
|
||||
className="hover:underline font-medium cursor-pointer"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
巡检极空间挂载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{SSH_LIST.map((item) => (
|
||||
<button
|
||||
key={item.cmd}
|
||||
onClick={() => copyCmd(item.cmd, item.label)}
|
||||
className="hl-card hl-card-interactive px-4 py-3 text-left flex items-center justify-between gap-2 cursor-pointer"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold">{item.label}</div>
|
||||
<div
|
||||
className="text-[11px] font-mono-num truncate mt-0.5"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{item.cmd}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-mono-num px-1.5 py-0.5 rounded hl-muted shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
COPY
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import { Search, Terminal } from 'lucide-react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
import type { RouteModeId, ThemeId } from '../types/telemetry';
|
||||
|
||||
const THEMES: Array<{ id: ThemeId; label: string; dot: string }> = [
|
||||
{ id: 'zinc-dark', label: '深色极客', dot: '#18181b' },
|
||||
{ id: 'zinc-light', label: '浅色纯净', dot: '#f8fafc' },
|
||||
{ id: 'catppuccin', label: 'Catppuccin', dot: '#cba6f7' },
|
||||
{ id: 'nord', label: 'Nord', dot: '#88c0d0' },
|
||||
];
|
||||
|
||||
const MODES: Array<{ id: RouteModeId; label: string }> = [
|
||||
{ id: 'auto', label: '智能路由' },
|
||||
{ id: 'edge443', label: '443 免端口' },
|
||||
{ id: 'home10443', label: '10443 家宽' },
|
||||
{ id: 'lan', label: 'LAN 局域网' },
|
||||
];
|
||||
|
||||
export const TopRibbon: React.FC = () => {
|
||||
const {
|
||||
theme,
|
||||
routeMode,
|
||||
telemetry,
|
||||
setTheme,
|
||||
setRouteMode,
|
||||
setShowAgyModal,
|
||||
setShowCmdPalette,
|
||||
} = usePortalStore();
|
||||
|
||||
return (
|
||||
<header
|
||||
className="border-b hl-border sticky top-0 z-30 backdrop-blur-md"
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--bg-page) 86%, transparent)' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Left: Brand + Live System Micro-Gauges Ribbon */}
|
||||
<div className="flex flex-wrap items-center gap-2.5 sm:gap-3">
|
||||
<div className="flex items-center gap-2.5 pr-1">
|
||||
<img
|
||||
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png"
|
||||
alt="Mac mini"
|
||||
className="w-6 h-6 rounded"
|
||||
/>
|
||||
<span className="font-semibold text-sm tracking-tight">HomeLab</span>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: 'var(--success)' }}
|
||||
title="全屋 4 节点在线"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden xl:flex items-center gap-2 text-xs font-mono-num">
|
||||
{/* CPU Gauge */}
|
||||
<div className="hl-pill px-2.5 py-1 flex items-center gap-2" title="Apple M6 12核处理器负载">
|
||||
<span style={{ color: 'var(--text-muted)' }}>M6 CPU</span>
|
||||
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full hl-bar-fill"
|
||||
style={{ width: `${Math.max(5, telemetry.mini.cpu_pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium">{telemetry.mini.cpu_pct}%</span>
|
||||
</div>
|
||||
|
||||
{/* RAM Gauge */}
|
||||
<div className="hl-pill px-2.5 py-1 flex items-center gap-2" title="Mac mini 24GB 统一内存">
|
||||
<span style={{ color: 'var(--text-muted)' }}>RAM</span>
|
||||
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full hl-bar-fill"
|
||||
style={{ width: `${Math.max(5, telemetry.mini.mem_pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium">{telemetry.mini.mem_used_gb}G</span>
|
||||
</div>
|
||||
|
||||
{/* ZSpace 20TB Gauge */}
|
||||
<div className="hl-pill px-2.5 py-1 flex items-center gap-2" title="极空间 Z4 20TB 存储阵列">
|
||||
<span style={{ color: 'var(--text-muted)' }}>NAS 余量</span>
|
||||
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full hl-bar-fill-green"
|
||||
style={{ width: `${Math.max(5, telemetry.nas.pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium" style={{ color: 'var(--success)' }}>
|
||||
{telemetry.nas.free_tb} TB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* SG AI Line Gauge */}
|
||||
<div className="hl-pill px-2.5 py-1 flex items-center gap-1.5" title="AGY 锁定新加坡 Azure 专线出口">
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: 'var(--success)' }} />
|
||||
<span style={{ color: 'var(--text-muted)' }}>SG 专线</span>
|
||||
<span className="font-medium">
|
||||
{telemetry.network.sg_delay_ms > 0 ? `${telemetry.network.sg_delay_ms}ms` : '直连'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Search (Cmd+K), Route Switcher, Theme Switcher, Terminal Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowCmdPalette(true)}
|
||||
className="hl-btn-outline px-2.5 py-1 text-xs flex items-center gap-3 cursor-pointer"
|
||||
title="按 Cmd+K 唤起全局命令面板"
|
||||
>
|
||||
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">搜索或执行指令...</span>
|
||||
</span>
|
||||
<kbd
|
||||
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<div className="hidden md:flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setRouteMode(m.id)}
|
||||
className="px-2 py-1 rounded-md font-medium transition-all cursor-pointer"
|
||||
style={
|
||||
routeMode === m.id
|
||||
? {
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
color: 'var(--text-main)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||
}
|
||||
: { color: 'var(--text-muted)' }
|
||||
}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTheme(t.id)}
|
||||
className="px-2 py-1 rounded-md font-medium transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
style={
|
||||
theme === t.id
|
||||
? {
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
color: 'var(--text-main)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||
}
|
||||
: { color: 'var(--text-muted)' }
|
||||
}
|
||||
title={t.label}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full border border-black/10"
|
||||
style={{ backgroundColor: t.dot }}
|
||||
/>
|
||||
<span className="hidden lg:inline">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAgyModal(true)}
|
||||
className="hl-btn-primary px-3 py-1.5 text-xs flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
<span>AGY 终端</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* =========================================================================
|
||||
HomeLab Console V3 (React + Vite + TypeScript + Zustand + Tailwind v4)
|
||||
Architecture: gethomepage.dev (Top Ribbon + 3-Col Rows + Inline Widgets)
|
||||
Design System: shadcn/ui + Raycast Command Palette + 4 Official Themes
|
||||
========================================================================= */
|
||||
:root,
|
||||
[data-theme="zinc-dark"] {
|
||||
--bg-page: #09090b;
|
||||
--bg-card: #121215;
|
||||
--bg-widget: #16161a;
|
||||
--bg-muted: #18181b;
|
||||
--bg-hover: #1c1c21;
|
||||
--text-main: #f4f4f5;
|
||||
--text-muted: #a1a1aa;
|
||||
--text-subtle: #71717a;
|
||||
--border-color: #27272a;
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-hover: #3f3f46;
|
||||
--primary: #fafafa;
|
||||
--primary-fg: #09090b;
|
||||
--accent: #3b82f6;
|
||||
--accent-soft: rgba(59, 130, 246, 0.14);
|
||||
--success: #10b981;
|
||||
--success-soft: rgba(16, 185, 129, 0.14);
|
||||
--card-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
[data-theme="zinc-light"] {
|
||||
--bg-page: #f8fafc;
|
||||
--bg-card: #ffffff;
|
||||
--bg-widget: #f8fafc;
|
||||
--bg-muted: #f1f5f9;
|
||||
--bg-hover: #ffffff;
|
||||
--text-main: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--text-subtle: #94a3b8;
|
||||
--border-color: #e2e8f0;
|
||||
--border-subtle: #f1f5f9;
|
||||
--border-hover: #cbd5e1;
|
||||
--primary: #0f172a;
|
||||
--primary-fg: #ffffff;
|
||||
--accent: #2563eb;
|
||||
--accent-soft: rgba(37, 99, 235, 0.08);
|
||||
--success: #10b981;
|
||||
--success-soft: rgba(16, 185, 129, 0.1);
|
||||
--card-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.04), 0 1px 2px -1px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
[data-theme="catppuccin"] {
|
||||
--bg-page: #11111b;
|
||||
--bg-card: #1e1e2e;
|
||||
--bg-widget: #181825;
|
||||
--bg-muted: #313244;
|
||||
--bg-hover: #242438;
|
||||
--text-main: #cdd6f4;
|
||||
--text-muted: #a6adc8;
|
||||
--text-subtle: #6c7086;
|
||||
--border-color: #313244;
|
||||
--border-subtle: rgba(205, 214, 244, 0.06);
|
||||
--border-hover: #45475a;
|
||||
--primary: #cba6f7;
|
||||
--primary-fg: #11111b;
|
||||
--accent: #89b4fa;
|
||||
--accent-soft: rgba(137, 180, 250, 0.15);
|
||||
--success: #a6e3a1;
|
||||
--success-soft: rgba(166, 227, 161, 0.15);
|
||||
--card-shadow: 0 4px 16px -4px rgba(17, 17, 27, 0.6);
|
||||
}
|
||||
|
||||
[data-theme="nord"] {
|
||||
--bg-page: #242933;
|
||||
--bg-card: #2e3440;
|
||||
--bg-widget: #292e39;
|
||||
--bg-muted: #3b4252;
|
||||
--bg-hover: #353c4a;
|
||||
--text-main: #eceff4;
|
||||
--text-muted: #d8dee9;
|
||||
--text-subtle: #81a1c1;
|
||||
--border-color: #3b4252;
|
||||
--border-subtle: rgba(236, 239, 244, 0.06);
|
||||
--border-hover: #4c566a;
|
||||
--primary: #88c0d0;
|
||||
--primary-fg: #2e3440;
|
||||
--accent: #88c0d0;
|
||||
--accent-soft: rgba(136, 192, 208, 0.15);
|
||||
--success: #a3be8c;
|
||||
--success-soft: rgba(163, 190, 140, 0.15);
|
||||
--card-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background-color: var(--bg-page);
|
||||
color: var(--text-main);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.font-mono-num {
|
||||
font-family: "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hl-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 0.75rem;
|
||||
transition: border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.hl-card-interactive:hover {
|
||||
border-color: var(--border-hover);
|
||||
background-color: var(--bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.hl-widget-footer {
|
||||
background-color: var(--bg-widget);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hl-pill {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.hl-muted {
|
||||
background-color: var(--bg-muted);
|
||||
}
|
||||
|
||||
.hl-border {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.hl-btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.hl-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hl-btn-outline {
|
||||
background-color: var(--bg-card);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.hl-btn-outline:hover {
|
||||
background-color: var(--bg-muted);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.hl-bar-fill {
|
||||
background-color: var(--accent);
|
||||
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.hl-bar-fill-green {
|
||||
background-color: var(--success);
|
||||
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,154 @@
|
||||
import { create } from 'zustand';
|
||||
import type { RouteModeId, TelemetryData, ThemeId } from '../types/telemetry';
|
||||
|
||||
interface PortalState {
|
||||
theme: ThemeId;
|
||||
routeMode: RouteModeId;
|
||||
showAgyModal: boolean;
|
||||
showCmdPalette: boolean;
|
||||
toast: string;
|
||||
actionLoading: boolean;
|
||||
telemetry: TelemetryData;
|
||||
setTheme: (theme: ThemeId) => void;
|
||||
setRouteMode: (mode: RouteModeId) => void;
|
||||
setShowAgyModal: (open: boolean) => void;
|
||||
setShowCmdPalette: (open: boolean) => void;
|
||||
showToast: (msg: string) => void;
|
||||
fetchTelemetry: () => Promise<void>;
|
||||
triggerAction: (action: string, startMsg: string) => Promise<void>;
|
||||
copyCmd: (cmd: string, label: string) => Promise<void>;
|
||||
getEffectiveMode: () => Exclude<RouteModeId, 'auto'>;
|
||||
}
|
||||
|
||||
const initialTheme = (localStorage.getItem('hl_theme') as ThemeId) || 'catppuccin';
|
||||
const initialRouteMode = (localStorage.getItem('hl_route_mode') as RouteModeId) || 'auto';
|
||||
|
||||
// Apply theme immediately on load
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||
}
|
||||
|
||||
export const usePortalStore = create<PortalState>((set, get) => ({
|
||||
theme: initialTheme,
|
||||
routeMode: initialRouteMode,
|
||||
showAgyModal: false,
|
||||
showCmdPalette: false,
|
||||
toast: '',
|
||||
actionLoading: false,
|
||||
telemetry: {
|
||||
mini: {
|
||||
chip: 'Apple M6 (12核)',
|
||||
ip: '192.168.31.164',
|
||||
tailscale_ip: '100.108.205.40',
|
||||
cpu_pct: 6.5,
|
||||
load_1m: 0.85,
|
||||
mem_used_gb: 11.8,
|
||||
mem_total_gb: 24.0,
|
||||
mem_pct: 49.2,
|
||||
disk_used_gb: 59.9,
|
||||
disk_total_gb: 460.4,
|
||||
disk_pct: 13.0,
|
||||
uptime: '运行中',
|
||||
agy_tmux: true,
|
||||
},
|
||||
nas: {
|
||||
ip: '192.168.31.185',
|
||||
online: true,
|
||||
mounted: true,
|
||||
total_tb: 19.94,
|
||||
used_tb: 3.36,
|
||||
free_tb: 16.58,
|
||||
pct: 16.8,
|
||||
latest_backup: '11-06 23:17',
|
||||
},
|
||||
network: {
|
||||
home_wan_ip: '124.90.142.21',
|
||||
ai_policy: 'Ai平台 → 狮城节点',
|
||||
sg_node: '233boy-sea',
|
||||
sg_egress_ip: '104.43.105.243',
|
||||
sg_delay_ms: 68,
|
||||
},
|
||||
services: {
|
||||
agy: true,
|
||||
vaultwarden: true,
|
||||
nas: true,
|
||||
clash: true,
|
||||
router: true,
|
||||
uptime: true,
|
||||
},
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
localStorage.setItem('hl_theme', theme);
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
set({ theme });
|
||||
},
|
||||
|
||||
setRouteMode: (routeMode) => {
|
||||
localStorage.setItem('hl_route_mode', routeMode);
|
||||
set({ routeMode });
|
||||
},
|
||||
|
||||
setShowAgyModal: (showAgyModal) => set({ showAgyModal }),
|
||||
setShowCmdPalette: (showCmdPalette) => set({ showCmdPalette }),
|
||||
|
||||
showToast: (msg) => {
|
||||
set({ toast: msg });
|
||||
setTimeout(() => {
|
||||
if (get().toast === msg) {
|
||||
set({ toast: '' });
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
fetchTelemetry: async () => {
|
||||
try {
|
||||
const res = await fetch('/api/telemetry');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
set({ telemetry: data });
|
||||
}
|
||||
} catch {
|
||||
// ignore transient network errors
|
||||
}
|
||||
},
|
||||
|
||||
triggerAction: async (action, startMsg) => {
|
||||
const { showToast, fetchTelemetry } = get();
|
||||
set({ actionLoading: true });
|
||||
showToast(startMsg);
|
||||
try {
|
||||
const res = await fetch(`/api/action/${action}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showToast(data.message || '操作已完成');
|
||||
await fetchTelemetry();
|
||||
} catch {
|
||||
showToast('请求失败,请稍后重试');
|
||||
} finally {
|
||||
set({ actionLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
copyCmd: async (cmd, label) => {
|
||||
const { showToast } = get();
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
showToast(`已复制「${label}」: ${cmd}`);
|
||||
} catch {
|
||||
showToast(cmd);
|
||||
}
|
||||
},
|
||||
|
||||
getEffectiveMode: () => {
|
||||
const { routeMode } = get();
|
||||
if (routeMode !== 'auto') return routeMode;
|
||||
const host = window.location.hostname;
|
||||
if (host.startsWith('192.168.') || host.startsWith('100.') || host === 'localhost') {
|
||||
return 'lan';
|
||||
}
|
||||
if (host.includes('.home.961504.xyz') || window.location.port === '10443') {
|
||||
return 'home10443';
|
||||
}
|
||||
return 'edge443';
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,84 @@
|
||||
export type ThemeId = 'zinc-dark' | 'zinc-light' | 'catppuccin' | 'nord';
|
||||
export type RouteModeId = 'auto' | 'edge443' | 'home10443' | 'lan';
|
||||
|
||||
export interface TelemetryData {
|
||||
timestamp?: string;
|
||||
mini: {
|
||||
chip: string;
|
||||
ip: string;
|
||||
tailscale_ip: string;
|
||||
cpu_pct: number;
|
||||
load_1m: number;
|
||||
cores?: number;
|
||||
mem_used_gb: number;
|
||||
mem_total_gb: number;
|
||||
mem_pct: number;
|
||||
disk_used_gb: number;
|
||||
disk_total_gb: number;
|
||||
disk_pct: number;
|
||||
uptime: string;
|
||||
containers?: Array<{ name: string; status: string; image: string }>;
|
||||
agy_tmux: boolean;
|
||||
};
|
||||
nas: {
|
||||
name?: string;
|
||||
ip: string;
|
||||
online: boolean;
|
||||
latency_ms?: number;
|
||||
mounted: boolean;
|
||||
total_tb: number;
|
||||
used_tb: number;
|
||||
free_tb: number;
|
||||
pct: number;
|
||||
latest_backup: string;
|
||||
backup_count?: number;
|
||||
};
|
||||
network: {
|
||||
home_wan_ip: string;
|
||||
router_ip?: string;
|
||||
router_online?: boolean;
|
||||
router_ms?: number;
|
||||
ai_policy: string;
|
||||
sg_node: string;
|
||||
sg_egress_ip: string;
|
||||
sg_delay_ms: number;
|
||||
azure_tailscale_ip?: string;
|
||||
azure_online?: boolean;
|
||||
azure_ms?: number;
|
||||
};
|
||||
services: {
|
||||
agy: boolean;
|
||||
vaultwarden: boolean;
|
||||
nas: boolean;
|
||||
clash: boolean;
|
||||
router: boolean;
|
||||
uptime: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatCell {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceCardItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
iconUrl: string;
|
||||
online: boolean;
|
||||
actionType?: 'drawer' | 'backup';
|
||||
desc: string;
|
||||
edgeUrl: string;
|
||||
homeUrl: string;
|
||||
lanUrl: string;
|
||||
stats: StatCell[];
|
||||
}
|
||||
|
||||
export interface ServiceGroup {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
showRouteHint?: boolean;
|
||||
items: ServiceCardItem[];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://192.168.31.164:3002',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user