2026-01-05 09:58:59 +07:00
"use client" ;
2026-04-10 18:16:14 +07:00
import { useState , useEffect , useRef } from "react" ;
2026-01-20 13:16:34 +07:00
import PropTypes from "prop-types" ;
2026-02-18 13:46:14 +07:00
import { Card , Button , Input , Modal , CardSkeleton , Toggle } from "@/shared/components" ;
2026-01-05 09:58:59 +07:00
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard" ;
2026-02-21 16:42:46 +07:00
const TUNNEL_BENEFITS = [
{ icon : "public" , title : "Access Anywhere" , desc : "Use your API from any network" },
{ icon : "group" , title : "Share Endpoint" , desc : "Share URL with team members" },
{ icon : "code" , title : "Use in Cursor/Cline" , desc : "Connect AI tools remotely" },
{ icon : "lock" , title : "Encrypted" , desc : "End-to-end TLS via Cloudflare" },
];
2026-04-10 18:16:14 +07:00
const TUNNEL_PING_INTERVAL_MS = 2000 ;
const TUNNEL_PING_MAX_MS = 300000 ;
2026-01-05 09:58:59 +07:00
export default function APIPageClient ({ machineId }) {
const [ keys , setKeys ] = useState ([]);
const [ loading , setLoading ] = useState ( true );
const [ showAddModal , setShowAddModal ] = useState ( false );
const [ newKeyName , setNewKeyName ] = useState ( "" );
const [ createdKey , setCreatedKey ] = useState ( null );
2026-02-21 16:42:46 +07:00
const [ requireApiKey , setRequireApiKey ] = useState ( false );
2026-04-11 14:56:35 +07:00
const [ requireLogin , setRequireLogin ] = useState ( true );
const [ hasPassword , setHasPassword ] = useState ( true );
const [ tunnelDashboardAccess , setTunnelDashboardAccess ] = useState ( false );
2026-04-10 18:16:14 +07:00
// Cloudflare Tunnel state
const [ tunnelChecking , setTunnelChecking ] = useState ( true );
2026-02-21 16:42:46 +07:00
const [ tunnelEnabled , setTunnelEnabled ] = useState ( false );
const [ tunnelUrl , setTunnelUrl ] = useState ( "" );
2026-03-19 23:47:13 +07:00
const [ tunnelPublicUrl , setTunnelPublicUrl ] = useState ( "" );
2026-02-21 16:42:46 +07:00
const [ tunnelLoading , setTunnelLoading ] = useState ( false );
const [ tunnelProgress , setTunnelProgress ] = useState ( "" );
const [ tunnelStatus , setTunnelStatus ] = useState ( null );
2026-04-10 18:16:14 +07:00
const [ showEnableTunnelModal , setShowEnableTunnelModal ] = useState ( false );
const [ showDisableTunnelModal , setShowDisableTunnelModal ] = useState ( false );
// Tailscale state
const [ tsEnabled , setTsEnabled ] = useState ( false );
const [ tsUrl , setTsUrl ] = useState ( "" );
const [ tsLoading , setTsLoading ] = useState ( false );
const [ tsProgress , setTsProgress ] = useState ( "" );
const [ tsStatus , setTsStatus ] = useState ( null );
const [ tsInstalled , setTsInstalled ] = useState ( null ); // null=checking, true/false
const [ tsInstalling , setTsInstalling ] = useState ( false );
const [ tsInstallLog , setTsInstallLog ] = useState ([]);
const [ tsSudoPassword , setTsSudoPassword ] = useState ( "" );
const [ tsConnecting , setTsConnecting ] = useState ( false );
const [ showTsModal , setShowTsModal ] = useState ( false );
const [ showDisableTsModal , setShowDisableTsModal ] = useState ( false );
const tsLogRef = useRef ( null );
2026-02-28 11:07:51 +07:00
// API key visibility toggle state
const [ visibleKeys , setVisibleKeys ] = useState ( new Set ());
2026-01-05 09:58:59 +07:00
const { copied , copy } = useCopyToClipboard ();
2026-04-10 18:16:14 +07:00
// Auto-scroll install log
useEffect (() => {
if ( tsLogRef . current ) tsLogRef . current . scrollTop = tsLogRef . current . scrollHeight ;
}, [ tsInstallLog ]);
2026-01-05 09:58:59 +07:00
useEffect (() => {
fetchData ();
2026-02-21 16:42:46 +07:00
loadSettings ();
2026-01-05 09:58:59 +07:00
}, []);
2026-02-21 16:42:46 +07:00
const loadSettings = async () => {
2026-04-10 18:16:14 +07:00
setTunnelChecking ( true );
2026-02-21 16:42:46 +07:00
try {
2026-04-10 18:16:14 +07:00
const [ settingsRes , statusRes ] = await Promise . all ([
2026-02-21 16:42:46 +07:00
fetch ( "/api/settings" ),
fetch ( "/api/tunnel/status" )
]);
if ( settingsRes . ok ) {
const data = await settingsRes . json ();
setRequireApiKey ( data . requireApiKey || false );
2026-04-11 14:56:35 +07:00
setRequireLogin ( data . requireLogin !== false );
setHasPassword ( data . hasPassword || false );
setTunnelDashboardAccess ( data . tunnelDashboardAccess || false );
2026-02-21 16:42:46 +07:00
}
2026-04-10 18:16:14 +07:00
if ( statusRes . ok ) {
const data = await statusRes . json ();
const tEnabled = data . tunnel ? . enabled || false ;
const tUrl = data . tunnel ? . tunnelUrl || "" ;
const tPublicUrl = data . tunnel ? . publicUrl || "" ;
setTunnelUrl ( tUrl );
setTunnelPublicUrl ( tPublicUrl );
const tsEn = data . tailscale ? . enabled || false ;
const tsUrlVal = data . tailscale ? . tunnelUrl || "" ;
setTsUrl ( tsUrlVal );
if ( tsEn && tsUrlVal ) {
setTsLoading ( true );
setTsProgress ( "Checking Tailscale..." );
const tsHealthUrl = ` ${ tsUrlVal } /api/health` ;
try {
const tsPing = await fetch ( tsHealthUrl , { mode : "no-cors" , cache : "no-store" });
if ( tsPing . ok || tsPing . type === "opaque" ) {
setTsEnabled ( true );
} else {
const ok = await pingTsHealth ( tsUrlVal );
setTsEnabled ( ok );
if ( ! ok ) setTsStatus ({ type : "warning" , message : "Tailscale not reachable." });
}
} catch {
const ok = await pingTsHealth ( tsUrlVal );
setTsEnabled ( ok );
if ( ! ok ) setTsStatus ({ type : "warning" , message : "Tailscale not reachable." });
} finally {
setTsLoading ( false );
setTsProgress ( "" );
}
} else {
setTsEnabled ( tsEn );
}
if ( tEnabled && ( tPublicUrl || tUrl )) {
// Ping once to verify reachable
const healthUrl = ` ${ tPublicUrl || tUrl } /api/health` ;
try {
2026-04-13 17:38:09 +07:00
const ping = await fetch ( healthUrl , { cache : "no-store" });
if ( ping . ok ) {
2026-04-10 18:16:14 +07:00
setTunnelEnabled ( true );
} else {
pingTunnelHealth ( tPublicUrl || tUrl );
}
} catch {
pingTunnelHealth ( tPublicUrl || tUrl );
}
} else {
setTunnelEnabled ( tEnabled );
}
2026-02-21 16:42:46 +07:00
}
} catch ( error ) {
console . log ( "Error loading settings:" , error );
2026-04-10 18:16:14 +07:00
} finally {
setTunnelChecking ( false );
2026-02-21 16:42:46 +07:00
}
};
2026-04-11 14:56:35 +07:00
const handleTunnelDashboardAccess = async ( value ) => {
try {
const res = await fetch ( "/api/settings" , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ tunnelDashboardAccess : value }),
});
if ( res . ok ) setTunnelDashboardAccess ( value );
} catch ( error ) {
console . log ( "Error updating tunnelDashboardAccess:" , error );
}
};
2026-02-21 16:42:46 +07:00
const handleRequireApiKey = async ( value ) => {
try {
const res = await fetch ( "/api/settings" , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ requireApiKey : value }),
});
if ( res . ok ) setRequireApiKey ( value );
} catch ( error ) {
console . log ( "Error updating requireApiKey:" , error );
}
};
const fetchData = async () => {
try {
const keysRes = await fetch ( "/api/keys" );
const keysData = await keysRes . json ();
if ( keysRes . ok ) {
setKeys ( keysData . keys || []);
}
} catch ( error ) {
console . log ( "Error fetching data:" , error );
} finally {
setLoading ( false );
}
};
2026-04-10 18:16:14 +07:00
// u2500u2500u2500 Cloudflare Tunnel handlers
// Ping tunnel health until reachable, also check backend status to detect process die
const pingTunnelHealth = async ( url ) => {
setTunnelLoading ( true );
setTunnelProgress ( "Waiting for tunnel ready..." );
const healthUrl = ` ${ url } /api/health` ;
const start = Date . now ();
while ( Date . now () - start < TUNNEL_PING_MAX_MS ) {
await new Promise (( r ) => setTimeout ( r , TUNNEL_PING_INTERVAL_MS ));
try {
const ping = await fetch ( healthUrl , { mode : "no-cors" , cache : "no-store" });
if ( ping . ok || ping . type === "opaque" ) {
setTunnelEnabled ( true );
setTunnelLoading ( false );
setTunnelProgress ( "" );
return true ;
}
} catch { /* not ready yet */ }
// Every 5 pings (~10s), check if backend process still alive
if (( Date . now () - start ) % 10000 < TUNNEL_PING_INTERVAL_MS ) {
try {
const statusRes = await fetch ( "/api/tunnel/status" );
if ( statusRes . ok ) {
const status = await statusRes . json ();
if ( ! status . tunnel ? . enabled ) {
setTunnelStatus ({ type : "error" , message : "Tunnel process stopped unexpectedly." });
setTunnelLoading ( false );
setTunnelProgress ( "" );
return false ;
}
}
} catch { /* ignore */ }
}
}
setTunnelStatus ({ type : "error" , message : "Tunnel created but not reachable. Please try again." });
setTunnelLoading ( false );
setTunnelProgress ( "" );
return false ;
};
2026-02-21 16:42:46 +07:00
const handleEnableTunnel = async () => {
2026-04-10 18:16:14 +07:00
setShowEnableTunnelModal ( false );
2026-02-21 16:42:46 +07:00
setTunnelLoading ( true );
setTunnelStatus ( null );
2026-04-10 18:16:14 +07:00
setTunnelProgress ( "Creating tunnel..." );
2026-02-21 16:42:46 +07:00
try {
2026-04-10 18:16:14 +07:00
const res = await fetch ( "/api/tunnel/enable" , { method : "POST" });
2026-02-21 16:42:46 +07:00
const data = await res . json ();
2026-04-10 18:16:14 +07:00
if ( ! res . ok ) {
2026-02-21 16:42:46 +07:00
setTunnelStatus ({ type : "error" , message : data . error || "Failed to enable tunnel" });
2026-04-10 18:16:14 +07:00
return ;
2026-02-21 16:42:46 +07:00
}
2026-04-10 18:16:14 +07:00
const url = data . publicUrl || data . tunnelUrl ;
if ( ! url ) {
setTunnelStatus ({ type : "error" , message : "No tunnel URL returned" });
return ;
}
setTunnelUrl ( data . tunnelUrl || "" );
setTunnelPublicUrl ( data . publicUrl || "" );
await pingTunnelHealth ( url );
2026-02-21 16:42:46 +07:00
} catch ( error ) {
2026-04-10 18:16:14 +07:00
setTunnelStatus ({ type : "error" , message : error . message });
2026-02-21 16:42:46 +07:00
} finally {
setTunnelLoading ( false );
setTunnelProgress ( "" );
}
};
const handleDisableTunnel = async () => {
setTunnelLoading ( true );
setTunnelStatus ( null );
try {
const res = await fetch ( "/api/tunnel/disable" , { method : "POST" });
const data = await res . json ();
if ( res . ok ) {
setTunnelEnabled ( false );
setTunnelUrl ( "" );
2026-03-19 23:47:13 +07:00
setTunnelPublicUrl ( "" );
2026-04-10 18:16:14 +07:00
setShowDisableTunnelModal ( false );
2026-02-21 16:42:46 +07:00
setTunnelStatus ({ type : "success" , message : "Tunnel disabled" });
} else {
setTunnelStatus ({ type : "error" , message : data . error || "Failed to disable tunnel" });
}
} catch ( error ) {
setTunnelStatus ({ type : "error" , message : error . message });
} finally {
setTunnelLoading ( false );
}
};
2026-02-09 11:27:41 +07:00
2026-04-10 18:16:14 +07:00
// u2500u2500u2500 Tailscale handlers
const checkTailscaleInstalled = async () => {
setTsInstalled ( null );
try {
const res = await fetch ( "/api/tunnel/tailscale-check" );
if ( res . ok ) {
const data = await res . json ();
setTsInstalled ( data . installed );
return data ;
}
} catch { /* ignore */ }
setTsInstalled ( false );
return { installed : false };
};
const handleInstallTailscale = async () => {
setTsInstalling ( true );
setTsStatus ( null );
setTsInstallLog ([]);
try {
const res = await fetch ( "/api/tunnel/tailscale-install" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ sudoPassword : tsSudoPassword }),
});
setTsSudoPassword ( "" );
const reader = res . body . getReader ();
const decoder = new TextDecoder ();
let buffer = "" ;
while ( true ) {
const { done , value } = await reader . read ();
if ( done ) break ;
buffer += decoder . decode ( value , { stream : true });
const parts = buffer . split ( "\n\n" );
buffer = parts . pop () || "" ;
for ( const part of parts ) {
const lines = part . split ( "\n" );
let event = "progress" ;
let data = null ;
for ( const line of lines ) {
if ( line . startsWith ( "event: " )) event = line . slice ( 7 ). trim ();
if ( line . startsWith ( "data: " )) {
try { data = JSON . parse ( line . slice ( 6 )); } catch { /* skip */ }
}
}
if ( ! data ) continue ;
if ( event === "progress" ) {
setTsInstallLog (( prev ) => [... prev . slice ( - 50 ), data . message ]);
} else if ( event === "done" ) {
setTsInstalled ( true );
setTsInstalling ( false );
return ;
} else if ( event === "error" ) {
setTsStatus ({ type : "error" , message : data . error || "Install failed" });
}
}
}
} catch ( e ) {
setTsStatus ({ type : "error" , message : e . message });
} finally {
setTsInstalling ( false );
}
};
// Ping Tailscale health until reachable
const pingTsHealth = async ( url ) => {
setTsProgress ( "Waiting for Tailscale ready..." );
const healthUrl = ` ${ url } /api/health` ;
const start = Date . now ();
while ( Date . now () - start < TUNNEL_PING_MAX_MS ) {
await new Promise (( r ) => setTimeout ( r , TUNNEL_PING_INTERVAL_MS ));
try {
const ping = await fetch ( healthUrl , { mode : "no-cors" , cache : "no-store" });
if ( ping . ok || ping . type === "opaque" ) return true ;
} catch { /* not ready yet */ }
}
return false ;
};
const handleConnectTailscale = async ( preOpenedTab ) => {
const tab = preOpenedTab || null ;
setShowTsModal ( false );
setTsConnecting ( true );
setTsLoading ( true );
setTsStatus ( null );
setTsProgress ( "Connecting..." );
try {
const res = await fetch ( "/api/tunnel/tailscale-enable" , { method : "POST" });
const data = await res . json ();
if ( res . ok && data . success ) {
if ( tab ) tab . close ();
setTsUrl ( data . tunnelUrl || "" );
const reachable = await pingTsHealth ( data . tunnelUrl );
if ( reachable ) {
setTsEnabled ( true );
setTsStatus ( null );
} else {
setTsEnabled ( true );
setTsStatus ({ type : "warning" , message : "Connected but not reachable yet." });
}
return ;
}
// Needs login: redirect pre-opened tab or open new
if ( data . needsLogin && data . authUrl ) {
if ( tab ) tab . location . href = data . authUrl ;
else window . open ( data . authUrl , "tailscale_auth" , "width=600,height=700" );
setTsProgress ( "Waiting for login..." );
for ( let i = 0 ; i < 40 ; i ++ ) {
await new Promise (( r ) => setTimeout ( r , 3000 ));
try {
const r2 = await fetch ( "/api/tunnel/tailscale-check" );
if ( r2 . ok ) {
const check = await r2 . json ();
if ( check . loggedIn ) {
setTsProgress ( "Starting funnel..." );
const res2 = await fetch ( "/api/tunnel/tailscale-enable" , { method : "POST" });
const data2 = await res2 . json ();
if ( res2 . ok && data2 . success ) {
if ( tab ) tab . close ();
setTsUrl ( data2 . tunnelUrl || "" );
const ok2 = await pingTsHealth ( data2 . tunnelUrl );
if ( ok2 ) {
setTsEnabled ( true );
setTsStatus ( null );
} else {
setTsEnabled ( true );
setTsStatus ({ type : "warning" , message : "Connected but not reachable yet." });
}
} else if ( data2 . funnelNotEnabled && data2 . enableUrl ) {
await pollFunnelEnable ( data2 . enableUrl , tab );
} else {
setTsStatus ({ type : "error" , message : data2 . error || "Failed to start funnel" });
}
return ;
}
}
} catch { /* retry */ }
}
setTsStatus ({ type : "error" , message : "Login timed out. Please try again." });
return ;
}
// Funnel not enabled: redirect pre-opened tab
if ( data . funnelNotEnabled && data . enableUrl ) {
await pollFunnelEnable ( data . enableUrl , tab );
return ;
}
if ( tab ) tab . close ();
setTsStatus ({ type : "error" , message : data . error || "Failed to connect" });
} catch ( error ) {
if ( tab ) tab . close ();
setTsStatus ({ type : "error" , message : error . message });
} finally {
setTsLoading ( false );
setTsConnecting ( false );
setTsProgress ( "" );
}
};
const pollFunnelEnable = async ( enableUrl , tab ) => {
if ( tab ) tab . location . href = enableUrl ;
else window . open ( enableUrl , "tailscale_auth" , "width=600,height=700" );
setTsProgress ( "Enable Funnel in browser, waiting..." );
for ( let i = 0 ; i < 40 ; i ++ ) {
await new Promise (( r ) => setTimeout ( r , 3000 ));
try {
const res = await fetch ( "/api/tunnel/tailscale-enable" , { method : "POST" });
const data = await res . json ();
if ( res . ok && data . success ) {
if ( tab ) tab . close ();
setTsUrl ( data . tunnelUrl || "" );
const ok3 = await pingTsHealth ( data . tunnelUrl );
if ( ok3 ) {
setTsEnabled ( true );
setTsStatus ( null );
} else {
setTsEnabled ( true );
setTsStatus ({ type : "warning" , message : "Connected but not reachable yet." });
}
return ;
}
if ( data . funnelNotEnabled ) continue ;
if ( data . error ) {
setTsStatus ({ type : "error" , message : data . error });
return ;
}
} catch { /* retry */ }
}
setTsStatus ({ type : "error" , message : "Timed out waiting for Funnel to be enabled." });
};
const handleDisableTailscale = async () => {
setTsLoading ( true );
setTsStatus ( null );
try {
const res = await fetch ( "/api/tunnel/tailscale-disable" , { method : "POST" });
const data = await res . json ();
if ( res . ok ) {
setTsEnabled ( false );
setTsUrl ( "" );
setShowDisableTsModal ( false );
setTsStatus ({ type : "success" , message : "Tailscale disabled" });
} else {
setTsStatus ({ type : "error" , message : data . error || "Failed to disable Tailscale" });
}
} catch ( e ) {
setTsStatus ({ type : "error" , message : e . message });
} finally {
setTsLoading ( false );
}
};
const handleOpenTsModal = async () => {
setTsStatus ( null );
setTsInstallLog ([]);
setShowTsModal ( true );
await checkTailscaleInstalled ();
};
2026-01-05 09:58:59 +07:00
const handleCreateKey = async () => {
if ( ! newKeyName . trim ()) return ;
try {
const res = await fetch ( "/api/keys" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ name : newKeyName }),
});
const data = await res . json ();
if ( res . ok ) {
setCreatedKey ( data . key );
await fetchData ();
setNewKeyName ( "" );
setShowAddModal ( false );
}
} catch ( error ) {
console . log ( "Error creating key:" , error );
}
};
const handleDeleteKey = async ( id ) => {
if ( ! confirm ( "Delete this API key?" )) return ;
try {
const res = await fetch ( `/api/keys/ ${ id } ` , { method : "DELETE" });
if ( res . ok ) {
setKeys ( keys . filter (( k ) => k . id !== id ));
2026-02-28 11:07:51 +07:00
// Clean up visibility state
setVisibleKeys ( prev => {
const next = new Set ( prev );
next . delete ( id );
return next ;
});
2026-01-05 09:58:59 +07:00
}
} catch ( error ) {
console . log ( "Error deleting key:" , error );
}
};
2026-02-20 15:07:12 +07:00
const handleToggleKey = async ( id , isActive ) => {
try {
const res = await fetch ( `/api/keys/ ${ id } ` , {
method : "PUT" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ isActive }),
});
if ( res . ok ) {
setKeys ( prev => prev . map ( k => k . id === id ? { ... k , isActive } : k ));
}
} catch ( error ) {
console . log ( "Error toggling key:" , error );
}
};
2026-02-28 11:07:51 +07:00
const maskKey = ( fullKey ) => {
if ( ! fullKey ) return "" ;
return fullKey . length > 8 ? fullKey . slice ( 0 , 8 ) + "..." : fullKey ;
};
const toggleKeyVisibility = ( keyId ) => {
setVisibleKeys ( prev => {
const next = new Set ( prev );
if ( next . has ( keyId )) next . delete ( keyId );
else next . add ( keyId );
return next ;
});
};
2026-01-20 13:16:34 +07:00
const [ baseUrl , setBaseUrl ] = useState ( "/v1" );
2026-01-05 09:58:59 +07:00
2026-01-20 13:16:34 +07:00
// Hydration fix: Only access window on client side
useEffect (() => {
if ( typeof window !== "undefined" ) {
setBaseUrl ( ` ${ window . location . origin } /v1` );
}
}, []);
2026-01-05 09:58:59 +07:00
if ( loading ) {
return (
< div className = "flex flex-col gap-8" >
< CardSkeleton />
< CardSkeleton />
< /div>
);
}
2026-04-10 18:16:14 +07:00
const currentEndpoint = baseUrl ;
2026-01-05 09:58:59 +07:00
return (
< div className = "flex flex-col gap-8" >
{ /* Endpoint Card */ }
2026-02-09 11:27:41 +07:00
< Card >
2026-04-10 18:16:14 +07:00
< h2 className = "text-lg font-semibold mb-4" > API Endpoint < /h2>
{ /* Endpoint rows */ }
< div className = "flex flex-col gap-2" >
{ /* Local */ }
< EndpointRow
label = "Local"
url = { currentEndpoint }
copyId = "local_url"
copied = { copied }
onCopy = { copy }
/>
{ /* Cloudflare Tunnel */ }
2026-01-05 09:58:59 +07:00
< div className = "flex items-center gap-2" >
2026-04-10 18:16:14 +07:00
< span className = { `text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[68px] text-center ${
tunnelEnabled ? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400" : "bg-sidebar text-text-muted"
} ` } > Tunnel < /span>
{ tunnelEnabled && ! tunnelLoading ? (
<>
< Input value = { ` ${ tunnelPublicUrl || tunnelUrl } /v1` } readOnly className = "flex-1 font-mono text-sm" />
< button
onClick = {() => copy ( ` ${ tunnelPublicUrl || tunnelUrl } /v1` , "tunnel_url" )}
className = "p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
>
< span className = "material-symbols-outlined text-[18px]" > { copied === "tunnel_url" ? "check" : "content_copy" } < /span>
< /button>
< button
onClick = {() => setShowDisableTunnelModal ( true )}
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
title = "Disable Tunnel"
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
) : tunnelLoading ? (
2026-04-13 15:52:01 +07:00
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
{ tunnelProgress || "Creating tunnel..." }
< /div>
< button
2026-04-13 16:49:34 +07:00
onClick = {() => { setTunnelLoading ( false ); setTunnelProgress ( "" ); }}
2026-04-13 15:52:01 +07:00
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
2026-04-13 16:49:34 +07:00
title = "Stop"
2026-04-13 15:52:01 +07:00
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-04-10 18:16:14 +07:00
) : tunnelStatus ? . type === "error" ? (
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-red-300 dark:border-red-800 bg-red-500/5 text-sm text-red-600 dark:text-red-400" >
< span className = "material-symbols-outlined text-sm" > error < /span>
{ tunnelStatus . message }
< /div>
< Button size = "sm" icon = "cloud_upload" onClick = {() => setShowEnableTunnelModal ( true )} > Enable < /Button>
< />
) : tunnelChecking ? (
2026-04-13 15:52:01 +07:00
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
Checking ...
< /div>
< button
2026-04-13 16:49:34 +07:00
onClick = {() => setTunnelChecking ( false )}
2026-04-13 15:52:01 +07:00
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
2026-04-13 16:49:34 +07:00
title = "Stop"
2026-04-13 15:52:01 +07:00
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-01-05 09:58:59 +07:00
) : (
< Button
2026-04-10 18:16:14 +07:00
size = "sm"
2026-01-05 09:58:59 +07:00
icon = "cloud_upload"
2026-04-04 23:24:24 +07:00
onClick = {() => {
if ( ! requireApiKey ) {
setTunnelStatus ({ type : "error" , message : "Security required: Enable \"Require API key\" before activating the tunnel." });
return ;
}
2026-04-10 18:16:14 +07:00
setShowEnableTunnelModal ( true );
2026-04-04 23:24:24 +07:00
}}
2026-04-10 18:16:14 +07:00
className = "bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 text-white!"
2026-01-05 09:58:59 +07:00
>
2026-04-10 18:16:14 +07:00
Enable
< /Button>
)}
< /div>
{ /* Tailscale */ }
< div className = "flex items-center gap-2" >
< span className = { `text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[68px] text-center ${
tsEnabled ? "bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400" : "bg-sidebar text-text-muted"
} ` } > Tailscale < /span>
{ tsEnabled && ! tsLoading ? (
<>
< Input value = { ` ${ tsUrl } /v1` } readOnly className = "flex-1 font-mono text-sm" />
< button
onClick = {() => copy ( ` ${ tsUrl } /v1` , "ts_url" )}
className = "p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
>
< span className = "material-symbols-outlined text-[18px]" > { copied === "ts_url" ? "check" : "content_copy" } < /span>
< /button>
< button
onClick = {() => setShowDisableTsModal ( true )}
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
title = "Disable Tailscale"
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
) : ( tsLoading || tsConnecting ) ? (
2026-04-13 15:52:01 +07:00
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
{ tsProgress || "Connecting..." }
< /div>
< button
2026-04-13 16:49:34 +07:00
onClick = {() => { setTsLoading ( false ); setTsConnecting ( false ); setTsProgress ( "" ); }}
2026-04-13 15:52:01 +07:00
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
2026-04-13 16:49:34 +07:00
title = "Stop"
2026-04-13 15:52:01 +07:00
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-04-10 18:16:14 +07:00
) : tsStatus ? . type === "error" ? (
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-red-300 dark:border-red-800 bg-red-500/5 text-sm text-red-600 dark:text-red-400" >
< span className = "material-symbols-outlined text-sm" > error < /span>
{ tsStatus . message }
< /div>
< Button size = "sm" icon = "vpn_lock" onClick = { handleOpenTsModal } > Enable < /Button>
< />
) : (
< Button
size = "sm"
icon = "vpn_lock"
onClick = { handleOpenTsModal }
className = "bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
>
Enable
2026-01-05 09:58:59 +07:00
< /Button>
)}
< /div>
< /div>
2026-04-11 14:56:35 +07:00
{ /* Security warnings when tunnel or tailscale is active */ }
{( tunnelEnabled || tsEnabled ) && (
< div className = "mt-4 flex flex-col gap-2" >
{ ! requireApiKey && (
< SecurityWarning
message = "Require API key is disabled — your endpoint is publicly accessible without authentication."
action = {{ label : "Enable" , href : "#require-api-key" }}
/>
)}
{( ! requireLogin || ! hasPassword ) && (
< SecurityWarning
message = {
! requireLogin
? "Require login is disabled — anyone can access your dashboard via tunnel."
: "Dashboard uses the default password — change it in Profile settings."
}
action = {{
label : ! requireLogin ? "Enable" : "Change password" ,
href : "/dashboard/profile" ,
}}
/>
)}
< /div>
)}
{ /* Tunnel dashboard access option */ }
{( tunnelEnabled || tsEnabled ) && (
< div className = "mt-4 pt-4 border-t border-border flex items-center gap-3" >
< Toggle
checked = { tunnelDashboardAccess }
onChange = {() => handleTunnelDashboardAccess ( ! tunnelDashboardAccess )}
/>
< div className = "flex items-center gap-1.5" >
< p className = "font-medium text-sm" > Allow dashboard access via tunnel < /p>
2026-04-13 17:38:09 +07:00
< Tooltip text = "When enabled, the dashboard can be accessed through your tunnel or Tailscale URL (login still required). When disabled, dashboard access via tunnel/Tailscale is completely blocked." />
2026-04-11 14:56:35 +07:00
< /div>
< /div>
)}
2026-01-05 09:58:59 +07:00
< /Card>
{ /* API Keys */ }
2026-04-11 14:56:35 +07:00
< Card id = "require-api-key" >
2026-01-05 09:58:59 +07:00
< div className = "flex items-center justify-between mb-4" >
< h2 className = "text-lg font-semibold" > API Keys < /h2>
< Button icon = "add" onClick = {() => setShowAddModal ( true )} >
Create Key
< /Button>
< /div>
2026-02-18 13:46:14 +07:00
< div className = "flex items-center justify-between pb-4 mb-4 border-b border-border" >
< div >
< p className = "font-medium" > Require API key < /p>
< p className = "text-sm text-text-muted" >
Requests without a valid key will be rejected
< /p>
< /div>
< Toggle
checked = { requireApiKey }
onChange = {() => handleRequireApiKey ( ! requireApiKey )}
/>
< /div>
2026-01-05 09:58:59 +07:00
{ keys . length === 0 ? (
2026-01-31 12:58:04 +07:00
< div className = "text-center py-12" >
< div className = "inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4" >
< span className = "material-symbols-outlined text-[32px]" > vpn_key < /span>
< /div>
< p className = "text-text-main font-medium mb-1" > No API keys yet < /p>
< p className = "text-sm text-text-muted mb-4" > Create your first API key to get started < /p>
< Button icon = "add" onClick = {() => setShowAddModal ( true )} >
Create Key
< /Button>
2026-01-05 09:58:59 +07:00
< /div>
) : (
2026-01-31 12:58:04 +07:00
< div className = "flex flex-col" >
{ keys . map (( key ) => (
< div
key = { key . id }
2026-02-20 15:07:12 +07:00
className = { `group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 ${ key . isActive === false ? "opacity-60" : "" } ` }
2026-01-31 12:58:04 +07:00
>
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium" > { key . name } < /p>
< div className = "flex items-center gap-2 mt-1" >
2026-02-28 11:07:51 +07:00
< code className = "text-xs text-text-muted font-mono" >
{ visibleKeys . has ( key . id ) ? key . key : maskKey ( key . key )}
< /code>
< button
onClick = {() => toggleKeyVisibility ( key . id )}
className = "p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
title = { visibleKeys . has ( key . id ) ? "Hide key" : "Show key" }
>
< span className = "material-symbols-outlined text-[14px]" >
{ visibleKeys . has ( key . id ) ? "visibility_off" : "visibility" }
< /span>
< /button>
2026-01-31 12:58:04 +07:00
< button
onClick = {() => copy ( key . key , key . id )}
className = "p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
>
< span className = "material-symbols-outlined text-[14px]" >
{ copied === key . id ? "check" : "content_copy" }
< /span>
< /button>
< /div>
< p className = "text-xs text-text-muted mt-1" >
Created { new Date ( key . createdAt ). toLocaleDateString ()}
< /p>
2026-02-20 15:07:12 +07:00
{ key . isActive === false && (
< p className = "text-xs text-orange-500 mt-1" > Paused < /p>
)}
< /div>
< div className = "flex items-center gap-2" >
< Toggle
size = "sm"
checked = { key . isActive ?? true }
onChange = {( checked ) => {
if ( key . isActive && ! checked ) {
if ( confirm ( `Pause API key " ${ key . name } "?\n\nThis key will stop working immediately but can be resumed later.` )) {
handleToggleKey ( key . id , checked );
}
} else {
handleToggleKey ( key . id , checked );
}
}}
title = { key . isActive ? "Pause key" : "Resume key" }
/>
< button
onClick = {() => handleDeleteKey ( key . id )}
className = "p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
< span className = "material-symbols-outlined text-[18px]" > delete < /span>
< /button>
2026-01-31 12:58:04 +07:00
< /div>
< /div>
))}
2026-01-05 09:58:59 +07:00
< /div>
)}
< /Card>
{ /* Add Key Modal */ }
< Modal
isOpen = { showAddModal }
title = "Create API Key"
onClose = {() => {
setShowAddModal ( false );
setNewKeyName ( "" );
}}
>
< div className = "flex flex-col gap-4" >
< Input
label = "Key Name"
value = { newKeyName }
onChange = {( e ) => setNewKeyName ( e . target . value )}
placeholder = "Production Key"
/>
< div className = "flex gap-2" >
< Button onClick = { handleCreateKey } fullWidth disabled = { ! newKeyName . trim ()} >
Create
< /Button>
< Button
onClick = {() => {
setShowAddModal ( false );
setNewKeyName ( "" );
}}
variant = "ghost"
fullWidth
>
Cancel
< /Button>
< /div>
< /div>
< /Modal>
{ /* Created Key Modal */ }
< Modal
isOpen = { !! createdKey }
title = "API Key Created"
onClose = {() => setCreatedKey ( null )}
>
< div className = "flex flex-col gap-4" >
< div className = "bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4" >
< p className = "text-sm text-yellow-800 dark:text-yellow-200 mb-2 font-medium" >
Save this key now !
< /p>
< p className = "text-sm text-yellow-700 dark:text-yellow-300" >
This is the only time you will see this key . Store it securely .
< /p>
< /div>
< div className = "flex gap-2" >
< Input
value = { createdKey || "" }
readOnly
className = "flex-1 font-mono text-sm"
/>
< Button
variant = "secondary"
icon = { copied === "created_key" ? "check" : "content_copy" }
onClick = {() => copy ( createdKey , "created_key" )}
>
{ copied === "created_key" ? "Copied!" : "Copy" }
< /Button>
< /div>
< Button onClick = {() => setCreatedKey ( null )} fullWidth >
Done
< /Button>
< /div>
< /Modal>
2026-02-21 16:42:46 +07:00
{ /* Enable Tunnel Modal */ }
< Modal
2026-04-10 18:16:14 +07:00
isOpen = { showEnableTunnelModal }
2026-02-21 16:42:46 +07:00
title = "Enable Tunnel"
2026-04-10 18:16:14 +07:00
onClose = {() => setShowEnableTunnelModal ( false )}
2026-02-21 16:42:46 +07:00
>
< div className = "flex flex-col gap-4" >
< div className = "bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4" >
< div className = "flex items-start gap-3" >
< span className = "material-symbols-outlined text-blue-600 dark:text-blue-400" > cloud_upload < /span>
< div >
< p className = "text-sm text-blue-800 dark:text-blue-200 font-medium mb-1" >
Cloudflare Tunnel
< /p>
< p className = "text-sm text-blue-700 dark:text-blue-300" >
Expose your local 9 Router to the internet . No port forwarding , no static IP needed . Share endpoint URL with your team or use it in Cursor , Cline , and other AI tools from anywhere .
< /p>
< /div>
< /div>
< /div>
< div className = "grid grid-cols-2 gap-3" >
{ TUNNEL_BENEFITS . map (( benefit ) => (
< div key = { benefit . title } className = "flex flex-col items-center text-center p-3 rounded-lg bg-sidebar/50" >
< span className = "material-symbols-outlined text-xl text-primary mb-1" > { benefit . icon } < /span>
< p className = "text-xs font-semibold" > { benefit . title } < /p>
< p className = "text-xs text-text-muted" > { benefit . desc } < /p>
< /div>
))}
< /div>
< p className = "text-xs text-text-muted" >
Requires outbound port 7844 ( TCP / UDP ). Connection may take 10 - 30 s .
< /p>
< div className = "flex gap-2" >
< Button
onClick = { handleEnableTunnel }
fullWidth
className = "bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 text-white!"
>
Start Tunnel
< /Button>
2026-04-10 18:16:14 +07:00
< Button onClick = {() => setShowEnableTunnelModal ( false )} variant = "ghost" fullWidth > Cancel < /Button>
2026-02-21 16:42:46 +07:00
< /div>
< /div>
< /Modal>
2026-04-10 18:16:14 +07:00
{ /* Disable Cloudflare Tunnel Modal */ }
2026-01-05 09:58:59 +07:00
< Modal
2026-04-10 18:16:14 +07:00
isOpen = { showDisableTunnelModal }
2026-02-21 16:42:46 +07:00
title = "Disable Tunnel"
2026-04-10 18:16:14 +07:00
onClose = {() => ! tunnelLoading && setShowDisableTunnelModal ( false )}
2026-01-05 09:58:59 +07:00
>
< div className = "flex flex-col gap-4" >
2026-04-10 18:16:14 +07:00
< p className = "text-sm text-text-muted" > The Cloudflare tunnel will be disconnected . Remote access via tunnel URL will stop working . < /p>
< div className = "flex gap-2" >
< Button onClick = { handleDisableTunnel } fullWidth disabled = { tunnelLoading } className = "bg-red-500! hover:bg-red-600! text-white!" >
{ tunnelLoading ? "Disabling..." : "Disable" }
< /Button>
< Button onClick = {() => setShowDisableTunnelModal ( false )} variant = "ghost" fullWidth disabled = { tunnelLoading } > Cancel < /Button>
< /div>
< /div>
< /Modal>
{ /* Tailscale Modal */ }
< Modal
isOpen = { showTsModal }
title = "Tailscale Funnel"
onClose = {() => { if ( ! tsInstalling ) { setShowTsModal ( false ); setTsSudoPassword ( "" ); setTsStatus ( null ); } }}
>
< div className = "flex flex-col gap-4" >
{ /* Checking state */ }
{ tsInstalled === null && (
< p className = "text-sm text-text-muted flex items-center gap-2" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
Checking ...
< /p>
)}
{ /* Not installed */ }
{ tsInstalled === false && ! tsInstalling && (
< div className = "flex flex-col gap-3" >
< p className = "text-sm text-text-muted" > Tailscale is not installed . Install it to enable Funnel . < /p>
< div className = "flex gap-2" >
< Button
onClick = { handleInstallTailscale }
fullWidth
className = "bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
>
Install Tailscale
< /Button>
< Button onClick = {() => setShowTsModal ( false )} variant = "ghost" fullWidth > Cancel < /Button>
2026-01-05 09:58:59 +07:00
< /div>
< /div>
2026-04-10 18:16:14 +07:00
)}
2026-01-05 09:58:59 +07:00
2026-04-10 18:16:14 +07:00
{ /* Installing with progress log */ }
{ tsInstalling && (
< div className = "flex flex-col gap-2" >
< div className = "flex items-center gap-2 text-sm text-text-muted" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
Installing Tailscale ...
< /div>
{ tsInstallLog . length > 0 && (
< div ref = { tsLogRef } className = "bg-black/5 dark:bg-white/5 rounded p-2 max-h-40 overflow-y-auto font-mono text-xs text-text-muted" >
{ tsInstallLog . map (( line , i ) => (
< div key = { i } > { line } < /div>
))}
< /div>
)}
< /div>
)}
2026-01-05 09:58:59 +07:00
2026-04-10 18:16:14 +07:00
{ /* Installed: show Connect button */ }
{ tsInstalled === true && ! tsInstalling && (
< div className = "flex flex-col gap-3" >
< div className = "flex items-center gap-2 text-sm text-green-600 dark:text-green-400" >
< span className = "material-symbols-outlined text-[16px]" > check_circle < /span>
Tailscale installed
< /div>
< div className = "flex gap-2" >
< Button
onClick = {() => {
const tab = window . open ( "" , "tailscale_auth" , "width=600,height=700" );
if ( tab ) tab . document . write ( "<p style='font-family:sans-serif;text-align:center;margin-top:40px'>Connecting to Tailscale...</p>" );
handleConnectTailscale ( tab );
}}
fullWidth
className = "bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
>
Connect
< /Button>
< Button onClick = {() => setShowTsModal ( false )} variant = "ghost" fullWidth > Cancel < /Button>
< /div>
< /div>
)}
{ tsStatus && < StatusAlert status = { tsStatus } /> }
< /div>
< /Modal>
{ /* Disable Tailscale Modal */ }
< Modal
isOpen = { showDisableTsModal }
title = "Disable Tailscale"
onClose = {() => ! tsLoading && setShowDisableTsModal ( false )}
>
< div className = "flex flex-col gap-4" >
< p className = "text-sm text-text-muted" > Tailscale Funnel will be stopped . Remote access via Tailscale URL will stop working . < /p>
2026-01-05 09:58:59 +07:00
< div className = "flex gap-2" >
2026-04-10 18:16:14 +07:00
< Button onClick = { handleDisableTailscale } fullWidth disabled = { tsLoading } className = "bg-red-500! hover:bg-red-600! text-white!" >
{ tsLoading ? "Disabling..." : "Disable" }
2026-01-05 09:58:59 +07:00
< /Button>
2026-04-10 18:16:14 +07:00
< Button onClick = {() => setShowDisableTsModal ( false )} variant = "ghost" fullWidth disabled = { tsLoading } > Cancel < /Button>
2026-01-05 09:58:59 +07:00
< /div>
< /div>
< /Modal>
< /div>
);
2026-01-19 02:15:11 -08:00
}
2026-04-10 18:16:14 +07:00
/** Reusable endpoint row component */
function EndpointRow ({ label , url , copyId , copied , onCopy , badge , actions }) {
return (
< div className = "flex items-center gap-2" >
< span className = { `text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[68px] text-center ${ badge === "CF" ? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400" :
badge === "TS" ? "bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400" :
"bg-sidebar text-text-muted"
} ` } > { label } < /span>
< Input value = { url } readOnly className = "flex-1 font-mono text-sm" />
< button
onClick = {() => onCopy ( url , copyId )}
className = "p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
>
< span className = "material-symbols-outlined text-[18px]" > { copied === copyId ? "check" : "content_copy" } < /span>
< /button>
{ actions }
< /div>
);
}
/** Reusable status alert */
function StatusAlert ({ status , className = "" }) {
// Render URLs in message as clickable links
const renderMessage = ( msg ) => {
const parts = msg . split ( /(https?:\/\/[^\s]+)/g );
return parts . map (( part , i ) =>
/^ https ?: \ / \ //.test(part)
? < a key = { i } href = { part } target = "_blank" rel = "noreferrer" className = "underline font-medium" > { part } < /a>
: part
);
};
return (
< div className = { `p-2 rounded text-sm ${ className } ${ status . type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
status . type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
status . type === "info" ? "bg-blue-500/10 text-blue-600 dark:text-blue-400" :
"bg-red-500/10 text-red-600 dark:text-red-400"
} ` } >
{ renderMessage ( status . message )}
< /div>
);
}
2026-04-11 14:56:35 +07:00
/** Inline tooltip, Claude Code CLI style */
function Tooltip ({ text }) {
return (
< span className = "relative group inline-flex items-center" >
< span className = "material-symbols-outlined text-[14px] text-text-muted cursor-help" > help < /span>
< span className = "pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg" >
{ text }
< /span>
< /span>
);
}
/** Security warning banner with optional action link */
function SecurityWarning ({ message , action }) {
return (
< div className = "flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400" >
< span className = "material-symbols-outlined text-[16px] shrink-0 mt-0.5" > warning < /span>
< p className = "text-xs flex-1" > { message } < /p>
{ action && (
< a
href = { action . href }
className = "text-xs font-medium underline shrink-0 hover:opacity-80"
onClick = { action . href . startsWith ( "#" ) ? ( e ) => {
e . preventDefault ();
document . getElementById ( action . href . slice ( 1 )) ? . scrollIntoView ({ behavior : "smooth" });
} : undefined }
>
{ action . label }
< /a>
)}
< /div>
);
}
2026-01-19 02:15:11 -08:00
APIPageClient . propTypes = {
2026-01-20 13:16:34 +07:00
machineId : PropTypes . string . isRequired ,
2026-02-08 16:45:31 +07:00
};