Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 953ba951db | |||
| 432d9d4ce1 | |||
| b171f745db | |||
| f5e70a78d8 | |||
| c5a2fc9105 | |||
| c9b0677fed | |||
| 0371a8884f | |||
| 75432a4bc7 | |||
| c519d41799 | |||
| ba90eff5db | |||
| 502d0b3986 |
@@ -0,0 +1,46 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.4.2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: pnpm type-check
|
||||||
|
|
||||||
|
- name: API tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Frontend tests
|
||||||
|
run: pnpm --filter @floci/frontend test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
+7
-1
@@ -23,13 +23,19 @@ RUN bun build --compile --minify src/index.ts --outfile server
|
|||||||
|
|
||||||
# Stage 3 — minimal runtime (no bun, no node_modules)
|
# Stage 3 — minimal runtime (no bun, no node_modules)
|
||||||
FROM alpine:3
|
FROM alpine:3
|
||||||
RUN apk add --no-cache ca-certificates libstdc++
|
RUN apk add --no-cache ca-certificates libstdc++ \
|
||||||
|
&& addgroup -S -g 10001 thecloud \
|
||||||
|
&& adduser -S -D -H -u 10001 -G thecloud thecloud
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=api-build /app/server ./server
|
COPY --from=api-build /app/server ./server
|
||||||
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
||||||
|
COPY packages/api/public ./public
|
||||||
COPY TheCloud.png ./public/TheCloud.png
|
COPY TheCloud.png ./public/TheCloud.png
|
||||||
COPY svgAsset/authentik.svg ./public/authentik.svg
|
COPY svgAsset/authentik.svg ./public/authentik.svg
|
||||||
COPY aws-icons-svg ./public/aws-icons
|
COPY aws-icons-svg ./public/aws-icons
|
||||||
ENV PORT=4500
|
ENV PORT=4500
|
||||||
EXPOSE 4500
|
EXPOSE 4500
|
||||||
|
USER thecloud
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-4500}/api/health" || exit 1
|
||||||
CMD ["./server"]
|
CMD ["./server"]
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Sign in · TheCloud</title>
|
|
||||||
<style>
|
|
||||||
:root[data-theme=dark]{--frame:#07111d;--rail:#0a1626;--rfg:#e8f1fa;--rmut:#8aa3bc;--rbd:rgba(232,241,250,.14);--ac:#6cc4ff;--on:#06223a;
|
|
||||||
--pbg:#08131f;--grid:rgba(232,241,250,.08);--o1:#6cc4ff;--o2:#ffb547;--pfg:#f2f8ff;--pmut:#a9c3dc;--tile:rgba(12,26,43,.72);--tbd:rgba(232,241,250,.14);--ic:#8fd3ff;--hi:#ffb547;--glow:rgba(108,196,255,.16);--err:#ff9a9a;--errbg:#3a1822}
|
|
||||||
:root[data-theme=light]{--frame:#eef4fa;--rail:#0e2238;--rfg:#f2f8ff;--rmut:#9db6cf;--rbd:rgba(242,248,255,.18);--ac:#7fd0ff;--on:#06223a;
|
|
||||||
--pbg:#eaf2fa;--grid:rgba(14,34,56,.09);--o1:#0a6fc2;--o2:#c26a00;--pfg:#0e2238;--pmut:#4a627a;--tile:rgba(255,255,255,.8);--tbd:rgba(14,34,56,.14);--ic:#0a6fc2;--hi:#c26a00;--glow:rgba(10,111,194,.14);--err:#ffb0b0;--errbg:#3a1822}
|
|
||||||
:root[data-theme=cyber]{--frame:#010305;--rail:#02080a;--rfg:#4eff96;--rmut:#2f9a68;--rbd:rgba(78,255,150,.3);--ac:#ffea00;--on:#02080a;
|
|
||||||
--pbg:#02060a;--grid:rgba(78,255,150,.16);--o1:#4eff96;--o2:#ff10f0;--pfg:#4eff96;--pmut:#2fb877;--tile:rgba(5,13,16,.8);--tbd:rgba(78,255,150,.34);--ic:#4eff96;--hi:#ff10f0;--glow:rgba(78,255,150,.18);--err:#ff8da1;--errbg:#2a0a14}
|
|
||||||
*{box-sizing:border-box;margin:0}
|
|
||||||
html,body{min-height:100%}
|
|
||||||
body{background:var(--frame);color:var(--rfg);font-family:system-ui,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
|
|
||||||
body.fade,body.fade *{transition:background-color .5s,color .5s,border-color .5s!important}
|
|
||||||
.mono{font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace}
|
|
||||||
.defs{position:absolute;width:0;height:0;overflow:hidden}
|
|
||||||
.defs .hi{stroke:var(--hi)}.defs .hf{fill:var(--hi);stroke:none}
|
|
||||||
|
|
||||||
.frame{min-height:100vh;display:grid;grid-template-columns:400px minmax(0,1fr)}
|
|
||||||
|
|
||||||
/* rail */
|
|
||||||
.rail{background:var(--rail);padding:44px 48px 32px;display:flex;flex-direction:column;justify-content:space-between;gap:40px}
|
|
||||||
.brand{font-size:34px;font-weight:800;letter-spacing:-.045em;color:var(--ac);line-height:1;text-decoration:none;display:inline-block}
|
|
||||||
.signin{margin-top:clamp(40px,14vh,150px)}
|
|
||||||
.signin h2{font-size:15px;font-weight:600;margin-bottom:6px}
|
|
||||||
.lead{font-size:14px;line-height:1.55;color:var(--rmut)}
|
|
||||||
.dest{display:none;margin-top:16px;padding:9px 12px;border:1px dashed var(--rbd);border-radius:10px;font-size:12.5px;color:var(--rmut);overflow-wrap:anywhere}
|
|
||||||
.dest.on{display:block}.dest span{color:var(--rfg)}
|
|
||||||
.alert{display:none;margin-top:16px;padding:12px 14px;border:1px solid currentColor;border-radius:12px;background:var(--errbg);color:var(--err);font-size:14px;line-height:1.45}
|
|
||||||
.alert.on{display:block}
|
|
||||||
.btn{margin-top:22px;width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:15px 22px;border:0;border-radius:999px;background:var(--ac);color:var(--on);font:700 15px system-ui,sans-serif;text-decoration:none;cursor:pointer;transition:transform .15s,box-shadow .2s,filter .2s}
|
|
||||||
.btn:hover{transform:translateY(-2px);box-shadow:0 12px 28px -10px var(--ac);filter:brightness(1.06)}
|
|
||||||
.btn:active{transform:none}
|
|
||||||
.btn:focus-visible,.tg button:focus-visible{outline:3px solid var(--rfg);outline-offset:3px}
|
|
||||||
.btn[aria-busy=true]{pointer-events:none;filter:saturate(.6)}
|
|
||||||
.sp{display:none;width:15px;height:15px;border:2.5px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
|
|
||||||
.btn[aria-busy=true] .sp{display:block}.btn[aria-busy=true] .sh{display:none}
|
|
||||||
@keyframes spin{to{transform:rotate(360deg)}}
|
|
||||||
.sso{margin-top:14px;font-size:12.5px;color:var(--rmut);text-align:center}
|
|
||||||
.foot{display:grid;gap:16px}
|
|
||||||
.tg{display:inline-flex;width:max-content;gap:2px;padding:3px;border:1px solid var(--rbd);border-radius:999px}
|
|
||||||
.tg button{width:30px;height:30px;border:0;border-radius:999px;background:none;color:var(--rmut);cursor:pointer;display:grid;place-items:center;transition:.25s}
|
|
||||||
.tg button:hover{color:var(--rfg)}
|
|
||||||
.tg button.on{background:var(--ac);color:var(--on)}
|
|
||||||
.legal{font-size:11.5px;line-height:1.55;color:var(--rmut)}
|
|
||||||
|
|
||||||
/* panel */
|
|
||||||
.dot{position:absolute;border-radius:50%;filter:blur(70px);opacity:.24;animation:dr 16s ease-in-out infinite;pointer-events:none}
|
|
||||||
.d1{width:460px;height:460px;top:-8%;left:-120px;background:var(--o1)}
|
|
||||||
.d2{width:540px;height:540px;bottom:-22%;right:-160px;background:var(--o2);animation-delay:-5s}
|
|
||||||
@keyframes dr{33%{transform:translate(60px,-40px) scale(1.1)}66%{transform:translate(-30px,50px) scale(.95)}}
|
|
||||||
.panel{position:relative;margin:20px;border-radius:28px;overflow:hidden;display:flex;align-items:center;justify-content:center;padding:56px 40px;
|
|
||||||
background:linear-gradient(var(--grid) 1px,transparent 1px) center/56px 56px,linear-gradient(90deg,var(--grid) 1px,transparent 1px) center/56px 56px,var(--pbg);color:var(--pfg);border:1px solid var(--tbd)}
|
|
||||||
.pglow{position:absolute;width:640px;height:640px;border-radius:50%;background:radial-gradient(circle,var(--glow),transparent 62%);transform:translate(-50%,-50%);pointer-events:none;opacity:0;transition:opacity .4s}
|
|
||||||
.inner{position:relative;width:100%;max-width:960px;text-align:center}
|
|
||||||
h1{font-family:ui-serif,"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;font-weight:500;font-size:clamp(2.2rem,4.6vw,4rem);line-height:1.08;letter-spacing:-.02em;max-width:15em;margin:0 auto 48px;text-wrap:balance}
|
|
||||||
h1::after{content:"";display:inline-block;width:.08em;height:.85em;margin-left:.12em;background:var(--hi);vertical-align:-.05em;animation:bl 1.1s steps(1) infinite}
|
|
||||||
@keyframes bl{50%{opacity:0}}
|
|
||||||
.tiles{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px;list-style:none;padding:0}
|
|
||||||
.tile{padding:22px 8px 18px;border-radius:18px;background:var(--tile);border:1px solid var(--tbd);display:grid;align-content:start;justify-items:center;gap:12px;opacity:0;animation:up .6s cubic-bezier(.2,.7,.2,1) forwards;transition:transform .25s,border-color .25s,box-shadow .25s}
|
|
||||||
.tile:hover{transform:translateY(-5px);border-color:var(--hi);box-shadow:0 18px 36px -18px rgba(0,0,0,.5)}
|
|
||||||
.tile:nth-child(2){animation-delay:.06s}.tile:nth-child(3){animation-delay:.12s}.tile:nth-child(4){animation-delay:.18s}.tile:nth-child(5){animation-delay:.24s}.tile:nth-child(6){animation-delay:.3s}
|
|
||||||
@keyframes up{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
|
|
||||||
.ic{width:52px;height:52px;color:var(--ic)}
|
|
||||||
.tile b{display:block;font-size:14px;font-weight:650;line-height:1.25}
|
|
||||||
.tile small{display:block;margin-top:3px;font-size:11.5px;color:var(--pmut)}
|
|
||||||
.tag{margin-top:40px;font-size:16px;color:var(--pmut)}
|
|
||||||
|
|
||||||
@media (max-width:980px){.tiles{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
|
||||||
@media (max-width:900px){
|
|
||||||
.frame{grid-template-columns:1fr}
|
|
||||||
.rail{padding:28px 24px 24px;gap:26px}.signin{margin-top:22px}
|
|
||||||
.foot{order:3}
|
|
||||||
.panel{margin:0 14px 14px;padding:40px 18px;border-radius:22px}
|
|
||||||
h1{margin-bottom:30px}.tag{display:none}
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}.tile{opacity:1}.pglow{display:none}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<svg class="defs" aria-hidden="true" focusable="false">
|
|
||||||
<symbol id="i-lambda" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 9h7l15 30"/><path class="hi" d="M24 25l-10 14"/></g></symbol>
|
|
||||||
<symbol id="i-s3" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="24" cy="12" rx="15" ry="5.5"/><path d="M9 12l3.4 25c.4 2.8 5.700 5 11.600 5s11.200-2.200 11.600-5L39 12"/><path class="hi" d="M11 23c3 2.600 8.200 3.800 13 3.800S34 25.600 37 23"/></g></symbol>
|
|
||||||
<symbol id="i-sqs" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="9" width="34" height="8" rx="4"/><rect x="7" y="20" width="34" height="8" rx="4"/><rect x="7" y="31" width="34" height="8" rx="4"/><path class="hi" d="M13 24h8m-3-3l3 3-3 3"/></g></symbol>
|
|
||||||
<symbol id="i-sns" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M13 13a16 16 0 0 0 0 22M35 13a16 16 0 0 1 0 22M19 18a8 8 0 0 0 0 12M29 18a8 8 0 0 1 0 12"/><circle class="hi hf" cx="24" cy="24" r="3.6"/></g></symbol>
|
|
||||||
<symbol id="i-ddb" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="24" cy="11" rx="14" ry="5.500"/><path d="M10 11v26c0 3 6.300 5.500 14 5.500S38 40 38 37V11"/><path class="hi" d="M10 24c0 3 6.300 5.500 14 5.500S38 27 38 24"/></g></symbol>
|
|
||||||
<symbol id="i-sfn" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 24h6c4 0 5-9 9-9h2M13 24h6c4 0 5 9 9 9h2"/><rect x="30" y="9" width="11" height="11" rx="3"/><rect x="30" y="28" width="11" height="11" rx="3"/><circle class="hi" cx="9" cy="24" r="4"/></g></symbol>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="frame">
|
|
||||||
<aside class="rail">
|
|
||||||
<div>
|
|
||||||
<a class="brand" href="/">TheCloud</a>
|
|
||||||
<div class="signin">
|
|
||||||
<h2>Welcome back</h2>
|
|
||||||
<p class="lead">Continue with your Authentik account to open the console.</p>
|
|
||||||
<div class="dest mono" id="dest">Landing on <span id="destPath"></span></div>
|
|
||||||
<div class="alert" id="alert" role="alert"></div>
|
|
||||||
<a class="btn" id="go" href="/api/auth/oidc/login">
|
|
||||||
<svg class="sh" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 3v6c0 4.500-3.200 8-8 9-4.800-1-8-4.500-8-9V6l8-3z"/><path d="M9 12l2 2 4-4"/></svg>
|
|
||||||
<span class="sp" aria-hidden="true"></span><span id="label">Sign in with Authentik</span>
|
|
||||||
</a>
|
|
||||||
<p class="sso">Single sign-on. MFA happens there.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="foot">
|
|
||||||
<div class="tg" role="group" aria-label="Theme">
|
|
||||||
<button data-t="dark" aria-label="Dark theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M21 13.500A9 9 0 1 1 10.500 3a7 7 0 0 0 10.500 10.500z"/></svg></button>
|
|
||||||
<button data-t="light" aria-label="Light theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.900 4.900l1.400 1.400M17.700 17.700l1.400 1.400M4.900 19.100l1.400-1.400M17.700 6.300l1.400-1.400"/></svg></button>
|
|
||||||
<button data-t="cyber" aria-label="Cyber theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.400" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l6 6-6 6M12 19h8"/></svg></button>
|
|
||||||
</div>
|
|
||||||
<p class="legal">TheCloud is powered by Floci and is not affiliated with Amazon Web Services.</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="panel" id="panel">
|
|
||||||
<div class="dot d1"></div><div class="dot d2"></div>
|
|
||||||
<div class="pglow" id="pglow"></div>
|
|
||||||
|
|
||||||
<div class="inner">
|
|
||||||
<h1>Your own cloud, on your own hardware.</h1>
|
|
||||||
<ul class="tiles">
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-lambda"/></svg><span><b>Invoke functions</b><small>Lambda</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-s3"/></svg><span><b>Store objects</b><small>S3</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sqs"/></svg><span><b>Queue messages</b><small>SQS</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sns"/></svg><span><b>Publish events</b><small>SNS</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-ddb"/></svg><span><b>Query tables</b><small>DynamoDB</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sfn"/></svg><span><b>Orchestrate workflows</b><small>Step Functions</small></span></li>
|
|
||||||
</ul>
|
|
||||||
<p class="tag">Build and test against AWS services locally, with SSO in front.</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
var root=document.documentElement,btns=document.querySelectorAll('.tg button');
|
|
||||||
function setTheme(t,anim){
|
|
||||||
if(anim){document.body.classList.add('fade');setTimeout(function(){document.body.classList.remove('fade')},600)}
|
|
||||||
root.setAttribute('data-theme',t);
|
|
||||||
try{localStorage.setItem('thecloud-theme',t)}catch(e){}
|
|
||||||
btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===t)});
|
|
||||||
}
|
|
||||||
var t0=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';
|
|
||||||
try{var sv=localStorage.getItem('thecloud-theme');if(sv==='dark'||sv==='light'||sv==='cyber')t0=sv}catch(e){}
|
|
||||||
setTheme(t0,false);
|
|
||||||
btns.forEach(function(b){b.addEventListener('click',function(){setTheme(b.dataset.t,true)})});
|
|
||||||
document.addEventListener('keydown',function(e){
|
|
||||||
if((e.key==='t'||e.key==='T')&&!e.metaKey&&!e.ctrlKey&&!e.altKey){
|
|
||||||
var o=['dark','light','cyber'];setTheme(o[(o.indexOf(root.getAttribute('data-theme'))+1)%3],true)}
|
|
||||||
});
|
|
||||||
|
|
||||||
var panel=document.getElementById('panel'),gl=document.getElementById('pglow');
|
|
||||||
panel.addEventListener('mousemove',function(e){var r=panel.getBoundingClientRect();gl.style.left=(e.clientX-r.left)+'px';gl.style.top=(e.clientY-r.top)+'px';gl.style.opacity='1'});
|
|
||||||
panel.addEventListener('mouseleave',function(){gl.style.opacity='0'});
|
|
||||||
|
|
||||||
var p=new URLSearchParams(location.search),go=document.getElementById('go'),label=document.getElementById('label');
|
|
||||||
var rt=p.get('returnTo')||'/';
|
|
||||||
if(!(rt.length<512&&rt.charAt(0)==='/'&&rt.charAt(1)!=='/'&&rt.charAt(1)!=='\\'&&!/[\u0000-\u001f]/.test(rt)))rt='/';
|
|
||||||
go.href='/api/auth/oidc/login?returnTo='+encodeURIComponent(rt);
|
|
||||||
if(rt!=='/'){document.getElementById('destPath').textContent=rt;document.getElementById('dest').className='dest mono on'}
|
|
||||||
var msgs={access_denied:'Your account is not allowed to use this console. Ask the admin to grant access in Authentik.',session_expired:'Your session ended. Sign in again to continue.',state_mismatch:'The sign-in request expired. Start again.',invalid_state:'The sign-in request expired. Start again.',oidc_failed:'Authentik could not complete the sign-in. Try again in a moment.'};
|
|
||||||
var er=p.get('error');
|
|
||||||
if(er){var a=document.getElementById('alert');a.textContent=Object.prototype.hasOwnProperty.call(msgs,er)?msgs[er]:'Sign-in did not complete. Try again.';a.className='alert on'}
|
|
||||||
go.addEventListener('click',function(ev){
|
|
||||||
if(go.getAttribute('aria-busy')==='true'){ev.preventDefault();return}
|
|
||||||
go.setAttribute('aria-busy','true');label.textContent='Redirecting to Authentik';
|
|
||||||
});
|
|
||||||
addEventListener('pageshow',function(){go.removeAttribute('aria-busy');label.textContent='Sign in with Authentik'});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const buttons = document.querySelectorAll('.tg button');
|
||||||
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
root.setAttribute('data-theme', theme);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('thecloud-theme', theme);
|
||||||
|
} catch (ignored) {
|
||||||
|
// Theme persistence is optional when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
buttons.forEach((button) => button.classList.toggle('on', button.dataset.t === theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
|
try {
|
||||||
|
const savedTheme = localStorage.getItem('thecloud-theme');
|
||||||
|
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
||||||
|
initialTheme = savedTheme;
|
||||||
|
}
|
||||||
|
} catch (ignored) {
|
||||||
|
// The OS preference remains the fallback when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
setTheme(initialTheme);
|
||||||
|
|
||||||
|
buttons.forEach((button) => button.addEventListener('click', () => setTheme(button.dataset.t)));
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey && !event.altKey && !/INPUT|TEXTAREA/.test(event.target.tagName)) {
|
||||||
|
const themes = ['dark', 'light', 'cyber'];
|
||||||
|
setTheme(themes[(themes.indexOf(root.getAttribute('data-theme')) + 1) % themes.length]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = ['hello, engineer.', 'aws s3 mb s3://my-bucket', 'your own AWS, on your own hardware.', 'sfn start-execution --local', 'welcome to TheCloud.'];
|
||||||
|
const typewriter = document.getElementById('tw');
|
||||||
|
let line = 0;
|
||||||
|
let count = 0;
|
||||||
|
let deleting = false;
|
||||||
|
if (reducedMotion) {
|
||||||
|
typewriter.textContent = lines[2];
|
||||||
|
} else {
|
||||||
|
function type() {
|
||||||
|
const word = lines[line];
|
||||||
|
if (deleting) {
|
||||||
|
count--;
|
||||||
|
typewriter.textContent = word.slice(0, count);
|
||||||
|
if (count === 0) {
|
||||||
|
deleting = false;
|
||||||
|
line = (line + 1) % lines.length;
|
||||||
|
setTimeout(type, 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(type, 35);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
typewriter.textContent = word.slice(0, count);
|
||||||
|
if (count === word.length) {
|
||||||
|
deleting = true;
|
||||||
|
setTimeout(type, 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(type, 75 + Math.random() * 50);
|
||||||
|
}
|
||||||
|
type();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hero = document.getElementById('top');
|
||||||
|
const glow = document.getElementById('grid-glow');
|
||||||
|
if (!reducedMotion) {
|
||||||
|
hero.addEventListener('mousemove', (event) => {
|
||||||
|
const rect = hero.getBoundingClientRect();
|
||||||
|
glow.style.left = `${event.clientX - rect.left}px`;
|
||||||
|
glow.style.top = `${event.clientY - rect.top}px`;
|
||||||
|
glow.style.opacity = '1';
|
||||||
|
});
|
||||||
|
hero.addEventListener('mouseleave', () => {
|
||||||
|
glow.style.opacity = '0';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = document.getElementById('bar');
|
||||||
|
addEventListener('scroll', () => {
|
||||||
|
const height = document.documentElement.scrollHeight - innerHeight;
|
||||||
|
progress.style.width = `${height > 0 ? scrollY / height * 100 : 0}%`;
|
||||||
|
}, {passive: true});
|
||||||
|
|
||||||
|
const form = document.querySelector('[data-sso-form]');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', () => {
|
||||||
|
const button = form.querySelector('.btn');
|
||||||
|
button.classList.add('is-loading');
|
||||||
|
button.disabled = true;
|
||||||
|
document.getElementById('label').textContent = 'Redirecting...';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = document.getElementById('cp');
|
||||||
|
if (copy) {
|
||||||
|
copy.addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText(document.getElementById('code').innerText).then(() => {
|
||||||
|
copy.textContent = 'copied';
|
||||||
|
copy.classList.add('ok');
|
||||||
|
setTimeout(() => {
|
||||||
|
copy.textContent = 'copy';
|
||||||
|
copy.classList.remove('ok');
|
||||||
|
}, 1800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -60,9 +60,9 @@ describe('AwsLightsailAdapter', () => {
|
|||||||
test('merges diagnostic host-port mappings into UI metadata', async () => {
|
test('merges diagnostic host-port mappings into UI metadata', async () => {
|
||||||
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
|
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
|
||||||
const fetchMock = globalThis.fetch
|
const fetchMock = globalThis.fetch
|
||||||
globalThis.fetch = async () => new Response(JSON.stringify({mappings: [
|
globalThis.fetch = (async () => new Response(JSON.stringify({mappings: [
|
||||||
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
|
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
|
||||||
]}))
|
]}))) as unknown as typeof fetch
|
||||||
try {
|
try {
|
||||||
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
|
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
|
||||||
expect(resource?.metadata.hostPortMappings).toEqual([
|
expect(resource?.metadata.hostPortMappings).toEqual([
|
||||||
|
|||||||
@@ -106,6 +106,27 @@ describe('login throttling', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('post-login redirect', () => {
|
||||||
|
async function redirectAfterLogin(returnTo: string) {
|
||||||
|
const response = await request(app(localConfig()), '/api/auth/login', '198.51.100.40', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
|
||||||
|
body: new URLSearchParams({username: 'admin', password: 'correct-horse-battery', csrf: 'csrf-token', returnTo}),
|
||||||
|
})
|
||||||
|
return (await response.json() as {redirectTo: string}).redirectTo
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps same-origin paths with their query and fragment', async () => {
|
||||||
|
expect(await redirectAfterLogin('/cloud-explorer/aws/storage?search=logs#top')).toBe('/cloud-explorer/aws/storage?search=logs#top')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses destinations that a browser would resolve to another origin', async () => {
|
||||||
|
for (const returnTo of ['//evil.example', '/\\evil.example', '/\\/evil.example', '/\t/evil.example', 'https://evil.example', '/%0d%0a/evil.example'.replace('%0d%0a', '\r\n')]) {
|
||||||
|
expect(await redirectAfterLogin(returnTo)).toBe('/console/aws')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('OIDC authorization', () => {
|
describe('OIDC authorization', () => {
|
||||||
let privateKey: CryptoKey
|
let privateKey: CryptoKey
|
||||||
let jwk: JWK
|
let jwk: JWK
|
||||||
|
|||||||
@@ -288,9 +288,21 @@ function loginErrorMessage(code: string | undefined): string | null {
|
|||||||
return 'We could not complete your sign-in. Please try again.'
|
return 'We could not complete your sign-in. Please try again.'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPublic(path: string): boolean { return path === '/login' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
||||||
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
||||||
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' }
|
// Browsers treat `\` like `/` and drop tabs/newlines in URLs, so `/\evil.example` would
|
||||||
|
// otherwise pass a plain prefix check and land on another origin.
|
||||||
|
function safeReturnTo(value?: string): string {
|
||||||
|
const fallback = '/console/aws'
|
||||||
|
if (!value || !value.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(value)) return fallback
|
||||||
|
const base = 'http://return-to.invalid'
|
||||||
|
try {
|
||||||
|
const url = new URL(value, base)
|
||||||
|
return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
||||||
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
||||||
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
||||||
@@ -336,4 +348,4 @@ function parseTrustedProxies(value: string | undefined): BlockList | null {
|
|||||||
}
|
}
|
||||||
return proxies
|
return proxies
|
||||||
}
|
}
|
||||||
function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-mgwziK4YXYUt2nNB711bt/nNzs3je0rpErGfX/6eqmc='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') }
|
function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-AbRSUfgtaJwJXaS+/FXX4ATIx390dHnVN1Z7G7jqB8s='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') }
|
||||||
|
|||||||
@@ -77,6 +77,48 @@ export interface CloudServiceStatus {
|
|||||||
errorCode: string | null
|
errorCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One service as the runtime itself reports it, which can be far more than the console covers. */
|
||||||
|
export interface EmulatedServiceStatus {
|
||||||
|
/** The runtime's own service id, e.g. `states` or `elasticloadbalancing`. */
|
||||||
|
name: string
|
||||||
|
state: string
|
||||||
|
/** Console pages backed by this service; empty when the console has none yet. */
|
||||||
|
consolePages: Array<{service: CloudServiceType; displayName: string; route: string}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A container the runtime started to back a resource, as reported by its diagnostics. */
|
||||||
|
export interface RuntimeContainer {
|
||||||
|
kind: 'ec2-instance'
|
||||||
|
resourceId: string
|
||||||
|
status: string | null
|
||||||
|
sshHost: string | null
|
||||||
|
sshPort: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDiagnostics {
|
||||||
|
cloud: CloudProvider
|
||||||
|
checkedAt: string
|
||||||
|
runtime: {
|
||||||
|
status: RuntimeReachability
|
||||||
|
endpoint: string | null
|
||||||
|
latencyMs: number | null
|
||||||
|
error: string | null
|
||||||
|
version: string | null
|
||||||
|
edition: string | null
|
||||||
|
}
|
||||||
|
/** Null when the runtime does not publish a per-service list. */
|
||||||
|
emulatedServices: EmulatedServiceStatus[] | null
|
||||||
|
consoleServices: CloudServiceStatus[]
|
||||||
|
/** Null when the runtime does not publish container diagnostics. */
|
||||||
|
containers: RuntimeContainer[] | null
|
||||||
|
console: {
|
||||||
|
startedAt: string
|
||||||
|
uptimeSeconds: number
|
||||||
|
authMode: 'local' | 'oidc'
|
||||||
|
activeSessions: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
|
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
|
||||||
|
|
||||||
export interface FieldSchema {
|
export interface FieldSchema {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import server from './index'
|
||||||
|
|
||||||
|
describe('API surface hardening', () => {
|
||||||
|
test('serves an unauthenticated JSON health check', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/health'))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({status: 'ok'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('answers unknown API paths with JSON instead of the SPA shell', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/does-not-exist'))
|
||||||
|
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an oversized sign-in body before parsing it', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
|
||||||
|
body: `username=admin&password=${'x'.repeat(20 * 1024)}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(413)
|
||||||
|
expect(await res.json()).toEqual({error: 'Request body is too large'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the upload ceiling for object uploads', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/storage/resources/bucket-1/object?key=big.bin', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'content-type': 'application/octet-stream'},
|
||||||
|
body: new Uint8Array(11 * 1024 * 1024),
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies the default limit to other API routes', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/messaging/resources/queue-1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: new Uint8Array(11 * 1024 * 1024),
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(413)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import {authConfig, authMiddleware, authRoutes} from './auth';
|
import {authConfig, authMiddleware, authRoutes} from './auth';
|
||||||
@@ -44,9 +45,19 @@ app.use(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use("*", logger());
|
app.use("*", logger());
|
||||||
|
// Sign-in endpoints read the body before a session exists, so they get a small
|
||||||
|
// limit; uploads (S3 objects, Lambda ZIPs sent as base64, Lightsail bundles) keep
|
||||||
|
// Bun's default 128 MB ceiling.
|
||||||
|
app.use("/api/*", (c, next) =>
|
||||||
|
bodyLimit({
|
||||||
|
maxSize: requestBodyLimit(c.req.path),
|
||||||
|
onError: (c) => c.json({ error: "Request body is too large" }, 413),
|
||||||
|
})(c, next),
|
||||||
|
);
|
||||||
app.use("*", authMiddleware(authentication));
|
app.use("*", authMiddleware(authentication));
|
||||||
app.use("*", authRoutes(authentication));
|
app.use("*", authRoutes(authentication));
|
||||||
|
|
||||||
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
||||||
app.route("/api/eks", eks);
|
app.route("/api/eks", eks);
|
||||||
app.route("/api/ec2", ec2);
|
app.route("/api/ec2", ec2);
|
||||||
app.route("/api/secretsmanager", secretsmanager);
|
app.route("/api/secretsmanager", secretsmanager);
|
||||||
@@ -55,6 +66,7 @@ app.route("/api/msk", msk);
|
|||||||
app.route("/api/msk-connect", mskConnect);
|
app.route("/api/msk-connect", mskConnect);
|
||||||
app.route("/api/ses", ses);
|
app.route("/api/ses", ses);
|
||||||
app.route("/api/elasticache", elasticache);
|
app.route("/api/elasticache", elasticache);
|
||||||
|
app.all("/api/*", (c) => c.json({ error: "Not found" }, 404));
|
||||||
|
|
||||||
// Serve static frontend files when public/ directory is present (production)
|
// Serve static frontend files when public/ directory is present (production)
|
||||||
app.use("*", serveStatic({ root: "./public" }));
|
app.use("*", serveStatic({ root: "./public" }));
|
||||||
@@ -63,6 +75,16 @@ app.get("*", serveStatic({ path: "./public/index.html" }));
|
|||||||
const port = Number(process.env.PORT ?? 4501);
|
const port = Number(process.env.PORT ?? 4501);
|
||||||
export default { port, fetch: app.fetch };
|
export default { port, fetch: app.fetch };
|
||||||
|
|
||||||
|
function requestBodyLimit(path: string): number {
|
||||||
|
if (path.startsWith("/api/auth/")) return 16 * 1024;
|
||||||
|
if (
|
||||||
|
/^\/api\/clouds\/[^/]+\/services\/[^/]+\/resources(?:\/[^/]+(?:\/object|\/static-files|\/node-application)?)?$/.test(path)
|
||||||
|
) {
|
||||||
|
return 128 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
return 10 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
function isSensitiveCredentialRoute(path: string): boolean {
|
function isSensitiveCredentialRoute(path: string): boolean {
|
||||||
return (
|
return (
|
||||||
path.startsWith("/api/secretsmanager") ||
|
path.startsWith("/api/secretsmanager") ||
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function renderLoginPage(options: LoginPageOptions): string {
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<link rel="icon" href="/TheCloud.png" />
|
||||||
<title>Sign in · TheCloud</title>
|
<title>Sign in · TheCloud</title>
|
||||||
<style nonce="${options.styleNonce}">
|
<style nonce="${options.styleNonce}">
|
||||||
:root[data-theme=dark]{--bg:#07111d;--rail:#091727;--panel:#0c1a2b;--fg:#e8f1fa;--dim:#bdd0e3;--mut:#8aa3bc;--ac:#6cc4ff;--acr:108,196,255;--ac2:#ffb547;--bd:rgba(232,241,250,.1);--bd2:rgba(232,241,250,.2);--on:#06223a;--err:#ff9a9a;--errbg:#3a1822}
|
:root[data-theme=dark]{--bg:#07111d;--rail:#091727;--panel:#0c1a2b;--fg:#e8f1fa;--dim:#bdd0e3;--mut:#8aa3bc;--ac:#6cc4ff;--acr:108,196,255;--ac2:#ffb547;--bd:rgba(232,241,250,.1);--bd2:rgba(232,241,250,.2);--on:#06223a;--err:#ff9a9a;--errbg:#3a1822}
|
||||||
@@ -42,7 +43,7 @@ export function renderLoginPage(options: LoginPageOptions): string {
|
|||||||
<span class="f">aws</span> sqs create-queue <span class="o">--queue-name</span> fulfilment-queue
|
<span class="f">aws</span> sqs create-queue <span class="o">--queue-name</span> fulfilment-queue
|
||||||
<span class="f">aws</span> stepfunctions list-state-machines</code></pre></div></section>
|
<span class="f">aws</span> stepfunctions list-state-machines</code></pre></div></section>
|
||||||
<footer class="mono"><div><span><span class="prompt">$</span> TheCloud</span><span>press <kbd>T</kbd> to switch theme</span></div></footer></div></div>
|
<footer class="mono"><div><span><span class="prompt">$</span> TheCloud</span><span>press <kbd>T</kbd> to switch theme</span></div></footer></div></div>
|
||||||
<script nonce="${options.styleNonce}">(function(){var root=document.documentElement,btns=document.querySelectorAll('.tg button'),still=matchMedia('(prefers-reduced-motion: reduce)').matches;function setTheme(t){root.setAttribute('data-theme',t);try{localStorage.setItem('thecloud-theme',t)}catch(e){}btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===t)})}var initial=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';try{var saved=localStorage.getItem('thecloud-theme');if(saved==='dark'||saved==='light'||saved==='cyber')initial=saved}catch(e){}setTheme(initial);btns.forEach(function(b){b.addEventListener('click',function(){setTheme(b.dataset.t)})});document.addEventListener('keydown',function(e){if((e.key==='t'||e.key==='T')&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&!/INPUT|TEXTAREA/.test(e.target.tagName)){var themes=['dark','light','cyber'];setTheme(themes[(themes.indexOf(root.getAttribute('data-theme'))+1)%themes.length])}});var lines=['hello, engineer.','aws s3 mb s3://my-bucket','your own AWS, on your own hardware.','sfn start-execution --local','welcome to TheCloud.'],el=document.getElementById('tw'),line=0,count=0,deleting=false;if(still){el.textContent=lines[2]}else{(function type(){var word=lines[line];if(deleting){count--;el.textContent=word.slice(0,count);if(count===0){deleting=false;line=(line+1)%lines.length;setTimeout(type,400);return}setTimeout(type,35);return}count++;el.textContent=word.slice(0,count);if(count===word.length){deleting=true;setTimeout(type,2200);return}setTimeout(type,75+Math.random()*50)})()}var hero=document.getElementById('top'),glow=document.getElementById('grid-glow');if(!still){hero.addEventListener('mousemove',function(e){var rect=hero.getBoundingClientRect();glow.style.left=(e.clientX-rect.left)+'px';glow.style.top=(e.clientY-rect.top)+'px';glow.style.opacity='1'});hero.addEventListener('mouseleave',function(){glow.style.opacity='0'})}var bar=document.getElementById('bar');addEventListener('scroll',function(){var height=document.documentElement.scrollHeight-innerHeight;bar.style.width=(height>0?scrollY/height*100:0)+'%'},{passive:true});var form=document.querySelector('[data-sso-form]');if(form)form.addEventListener('submit',function(){var button=form.querySelector('.btn');button.classList.add('is-loading');button.disabled=true;document.getElementById('label').textContent='Redirecting...' });var copy=document.getElementById('cp');if(copy)copy.addEventListener('click',function(){navigator.clipboard.writeText(document.getElementById('code').innerText).then(function(){copy.textContent='copied';copy.classList.add('ok');setTimeout(function(){copy.textContent='copy';copy.classList.remove('ok')},1800)})})})()</script>
|
<script defer src="/login.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
|
|||||||
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
||||||
import {serviceForAccount} from '../cloudProxy'
|
import {serviceForAccount} from '../cloudProxy'
|
||||||
import {CloudProxyService} from '../service/CloudProxyService'
|
import {CloudProxyService} from '../service/CloudProxyService'
|
||||||
|
import {runtimeDiagnostics} from '../service/runtimeDiagnostics'
|
||||||
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
|
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
|
||||||
|
|
||||||
// Header (and query-param fallback for direct links such as object downloads)
|
// Header (and query-param fallback for direct links such as object downloads)
|
||||||
@@ -64,6 +65,12 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
return c.json(await svc(c).status(cloud, {includeServices}))
|
return c.json(await svc(c).status(cloud, {includeServices}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/diagnostics', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
return c.json(await runtimeDiagnostics(cloud, svc(c)))
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/:cloud/services/:service/status', async (c) => {
|
app.get('/:cloud/services/:service/status', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
const serviceType = c.req.param('service') as CloudServiceType
|
const serviceType = c.req.param('service') as CloudServiceType
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import type {CloudProvider, CloudServiceDescriptor, CloudStatus} from '../cloud-spi/types'
|
||||||
|
import {runtimeDiagnostics} from './runtimeDiagnostics'
|
||||||
|
|
||||||
|
const STARTED_AT = new Date('2026-10-23T08:00:00.000Z')
|
||||||
|
const NOW = new Date('2026-10-23T10:30:15.000Z')
|
||||||
|
|
||||||
|
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor {
|
||||||
|
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
|
||||||
|
return {
|
||||||
|
status: async (cloud: CloudProvider) => ({
|
||||||
|
cloud, adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), error: null,
|
||||||
|
services: [{cloud, service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), latencyMs: 4, error: null, errorCode: null}],
|
||||||
|
...status,
|
||||||
|
}) as CloudStatus,
|
||||||
|
services: () => descriptors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtime(responses: Record<string, unknown>): typeof fetch {
|
||||||
|
return (async (input: string | URL | Request) => {
|
||||||
|
const path = new URL(String(input instanceof Request ? input.url : input)).pathname
|
||||||
|
return path in responses ? Response.json(responses[path]) : new Response('not found', {status: 404})
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixed = {now: () => NOW, startedAt: STARTED_AT, authMode: 'oidc' as const, activeSessions: () => 2}
|
||||||
|
|
||||||
|
describe('runtimeDiagnostics', () => {
|
||||||
|
test('reports the runtime version, every emulated service and its console pages', async () => {
|
||||||
|
const report = await runtimeDiagnostics('aws', proxy({}, [
|
||||||
|
descriptor('storage', 'S3'),
|
||||||
|
descriptor('loadbalancing', 'Load Balancers'),
|
||||||
|
descriptor('targetgroups', 'Target Groups'),
|
||||||
|
descriptor('scheduler', 'Scheduler', 'coming_soon'),
|
||||||
|
]), {
|
||||||
|
...fixed,
|
||||||
|
fetch: runtime({'/_floci/health': {version: '1.9.0', edition: 'compat', services: {s3: 'running', elasticloadbalancing: 'running', route53: 'running', scheduler: 'stopped'}}}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(report.runtime).toMatchObject({status: 'reachable', endpoint: 'http://floci:4566', version: '1.9.0', edition: 'compat', error: null})
|
||||||
|
expect(report.runtime.latencyMs).toBeNumber()
|
||||||
|
expect(report.emulatedServices).toEqual([
|
||||||
|
{name: 'elasticloadbalancing', state: 'running', consolePages: [
|
||||||
|
{service: 'loadbalancing', displayName: 'Load Balancers', route: '/cloud-explorer/aws/loadbalancing'},
|
||||||
|
{service: 'targetgroups', displayName: 'Target Groups', route: '/cloud-explorer/aws/targetgroups'},
|
||||||
|
]},
|
||||||
|
{name: 'route53', state: 'running', consolePages: []},
|
||||||
|
{name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]},
|
||||||
|
{name: 'scheduler', state: 'stopped', consolePages: []},
|
||||||
|
])
|
||||||
|
expect(report.consoleServices).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists the containers the runtime started for EC2 instances', async () => {
|
||||||
|
const report = await runtimeDiagnostics('aws', proxy(), {
|
||||||
|
...fixed,
|
||||||
|
fetch: runtime({'/_floci/diagnose': {ec2: {instances: [
|
||||||
|
{instanceId: 'i-0abc', containerStatus: 'running', sshHost: 'localhost', sshPort: 2201},
|
||||||
|
{instanceId: 'i-0def', containerStatus: 'stopped'},
|
||||||
|
{containerStatus: 'running'},
|
||||||
|
]}}}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(report.containers).toEqual([
|
||||||
|
{kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201},
|
||||||
|
{kind: 'ec2-instance', resourceId: 'i-0def', status: 'stopped', sshHost: null, sshPort: null},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('still returns a partial report when the runtime is down', async () => {
|
||||||
|
const report = await runtimeDiagnostics('aws', proxy({runtime: 'unavailable', error: 'Cannot reach Floci core at http://floci:4566', services: []}), {
|
||||||
|
...fixed,
|
||||||
|
fetch: (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(report.runtime).toEqual({status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null})
|
||||||
|
expect(report.emulatedServices).toBeNull()
|
||||||
|
expect(report.containers).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not query Floci core endpoints for other clouds', async () => {
|
||||||
|
const requested: string[] = []
|
||||||
|
const report = await runtimeDiagnostics('azure', proxy(), {
|
||||||
|
...fixed,
|
||||||
|
fetch: (async (input: string | URL | Request) => { requested.push(String(input)); return Response.json({}) }) as unknown as typeof fetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requested).toEqual([])
|
||||||
|
expect(report.emulatedServices).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes the console process itself', async () => {
|
||||||
|
const report = await runtimeDiagnostics('aws', proxy(), {...fixed, fetch: runtime({})})
|
||||||
|
|
||||||
|
expect(report.checkedAt).toBe('2026-10-23T10:30:15.000Z')
|
||||||
|
expect(report.console).toEqual({startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9015, authMode: 'oidc', activeSessions: 2})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import {authStateSizes} from '../auth'
|
||||||
|
import type {
|
||||||
|
CloudProvider,
|
||||||
|
CloudServiceDescriptor,
|
||||||
|
CloudServiceType,
|
||||||
|
EmulatedServiceStatus,
|
||||||
|
RuntimeContainer,
|
||||||
|
RuntimeDiagnostics,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
import type {CloudProxyService} from './CloudProxyService'
|
||||||
|
import {awsEndpoint} from './runtimeProbe'
|
||||||
|
|
||||||
|
const DIAGNOSTICS_TIMEOUT_MS = 3_000
|
||||||
|
const processStartedAt = new Date()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floci core service ids mapped to the console services they back. Only the
|
||||||
|
* runtime's own ids are listed; whether a console page is actually available is
|
||||||
|
* still decided by the adapter registry through `services()`.
|
||||||
|
*/
|
||||||
|
const AWS_CONSOLE_SERVICES: Record<string, CloudServiceType[]> = {
|
||||||
|
apigateway: ['apigateway'],
|
||||||
|
appconfig: ['configuration'],
|
||||||
|
cloudformation: ['iac'],
|
||||||
|
codebuild: ['codebuild'],
|
||||||
|
codedeploy: ['codedeploy'],
|
||||||
|
codepipeline: ['codepipeline'],
|
||||||
|
dynamodb: ['nosql'],
|
||||||
|
ec2: ['compute', 'networking'],
|
||||||
|
ecr: ['ecr'],
|
||||||
|
ecs: ['ecs', 'fargate'],
|
||||||
|
eks: ['k8s'],
|
||||||
|
elasticache: ['elasticache'],
|
||||||
|
elasticloadbalancing: ['loadbalancing', 'targetgroups'],
|
||||||
|
email: ['email'],
|
||||||
|
events: ['events'],
|
||||||
|
iam: ['identity'],
|
||||||
|
kafka: ['msk'],
|
||||||
|
kafkaconnect: ['msk-connect'],
|
||||||
|
kinesis: ['kinesis'],
|
||||||
|
kms: ['kms'],
|
||||||
|
lambda: ['serverless'],
|
||||||
|
lightsail: ['lightsail'],
|
||||||
|
logs: ['logs'],
|
||||||
|
rds: ['database'],
|
||||||
|
'rds-data': ['database'],
|
||||||
|
s3: ['storage'],
|
||||||
|
secretsmanager: ['secrets'],
|
||||||
|
sns: ['notifications'],
|
||||||
|
sqs: ['messaging'],
|
||||||
|
ssm: ['parameters'],
|
||||||
|
states: ['workflows'],
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlociHealth = {services?: Record<string, unknown>; version?: unknown; edition?: unknown}
|
||||||
|
type FlociDiagnose = {ec2?: {instances?: Array<{instanceId?: unknown; containerStatus?: unknown; sshHost?: unknown; sshPort?: unknown}>}}
|
||||||
|
|
||||||
|
export type DiagnosticsDependencies = {
|
||||||
|
fetch?: typeof fetch
|
||||||
|
now?: () => Date
|
||||||
|
startedAt?: Date
|
||||||
|
authMode?: 'local' | 'oidc'
|
||||||
|
activeSessions?: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the console can learn about a runtime in one call. Each source is
|
||||||
|
* optional: a runtime that is down, or one that does not publish a service list
|
||||||
|
* or container diagnostics, still yields a partial report rather than an error.
|
||||||
|
*/
|
||||||
|
export async function runtimeDiagnostics(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
proxy: Pick<CloudProxyService, 'status' | 'services'>,
|
||||||
|
dependencies: DiagnosticsDependencies = {},
|
||||||
|
): Promise<RuntimeDiagnostics> {
|
||||||
|
const fetcher = dependencies.fetch ?? globalThis.fetch
|
||||||
|
const now = dependencies.now?.() ?? new Date()
|
||||||
|
const startedAt = dependencies.startedAt ?? processStartedAt
|
||||||
|
const flociCore = cloud === 'aws'
|
||||||
|
|
||||||
|
const [status, health, diagnose] = await Promise.all([
|
||||||
|
proxy.status(cloud, {includeServices: true}),
|
||||||
|
flociCore ? fetchJson<FlociHealth>(fetcher, `${awsEndpoint()}/_floci/health`) : null,
|
||||||
|
flociCore ? fetchJson<FlociDiagnose>(fetcher, `${awsEndpoint()}/_floci/diagnose`) : null,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
cloud,
|
||||||
|
checkedAt: now.toISOString(),
|
||||||
|
runtime: {
|
||||||
|
status: status.runtime,
|
||||||
|
endpoint: status.endpoint,
|
||||||
|
latencyMs: health?.latencyMs ?? null,
|
||||||
|
error: status.error,
|
||||||
|
version: stringOrNull(health?.body.version),
|
||||||
|
edition: stringOrNull(health?.body.edition),
|
||||||
|
},
|
||||||
|
emulatedServices: emulatedServices(health?.body.services, proxy.services(cloud)),
|
||||||
|
consoleServices: status.services ?? [],
|
||||||
|
containers: containers(diagnose?.body),
|
||||||
|
console: {
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
uptimeSeconds: Math.max(0, Math.floor((now.getTime() - startedAt.getTime()) / 1000)),
|
||||||
|
authMode: dependencies.authMode ?? (process.env.FLOCI_UI_AUTH_MODE === 'oidc' ? 'oidc' : 'local'),
|
||||||
|
activeSessions: (dependencies.activeSessions ?? (() => authStateSizes().sessions))(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emulatedServices(reported: Record<string, unknown> | undefined, descriptors: CloudServiceDescriptor[]): EmulatedServiceStatus[] | null {
|
||||||
|
if (!reported || typeof reported !== 'object') return null
|
||||||
|
const available = new Map(descriptors.filter((descriptor) => descriptor.availability === 'available').map((descriptor) => [descriptor.service, descriptor]))
|
||||||
|
return Object.entries(reported)
|
||||||
|
.map(([name, state]) => ({
|
||||||
|
name,
|
||||||
|
state: typeof state === 'string' ? state : 'unknown',
|
||||||
|
consolePages: (AWS_CONSOLE_SERVICES[name] ?? [])
|
||||||
|
.map((service) => available.get(service))
|
||||||
|
.filter((descriptor): descriptor is CloudServiceDescriptor => Boolean(descriptor))
|
||||||
|
.map(({service, displayName, route}) => ({service, displayName, route})),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function containers(diagnose: FlociDiagnose | undefined): RuntimeContainer[] | null {
|
||||||
|
const instances = diagnose?.ec2?.instances
|
||||||
|
if (!Array.isArray(instances)) return null
|
||||||
|
return instances
|
||||||
|
.filter((instance) => typeof instance.instanceId === 'string')
|
||||||
|
.map((instance) => ({
|
||||||
|
kind: 'ec2-instance' as const,
|
||||||
|
resourceId: instance.instanceId as string,
|
||||||
|
status: stringOrNull(instance.containerStatus),
|
||||||
|
sshHost: stringOrNull(instance.sshHost),
|
||||||
|
sshPort: typeof instance.sshPort === 'number' ? instance.sshPort : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(fetcher: typeof fetch, url: string): Promise<{body: T; latencyMs: number} | null> {
|
||||||
|
const startedAt = performance.now()
|
||||||
|
try {
|
||||||
|
const response = await fetcher(url, {signal: AbortSignal.timeout(DIAGNOSTICS_TIMEOUT_MS)})
|
||||||
|
if (!response.ok) return null
|
||||||
|
return {body: await response.json() as T, latencyMs: Math.round(performance.now() - startedAt)}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrNull(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value ? value : null
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
var t = localStorage.getItem("floci-theme");
|
var t = localStorage.getItem("floci-theme");
|
||||||
document.documentElement.setAttribute(
|
document.documentElement.setAttribute(
|
||||||
"data-theme",
|
"data-theme",
|
||||||
t === "light" ? "light" : "dark",
|
t === "light" || t === "cyber" ? t : "dark",
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage'
|
|||||||
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
||||||
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
||||||
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
||||||
|
import {DiagnosticsPage} from '@/pages/DiagnosticsPage'
|
||||||
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
|
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
|
||||||
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
|
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
|
||||||
|
|
||||||
@@ -15,8 +16,16 @@ export default function App() {
|
|||||||
<Route index element={<Navigate to="/console/aws" replace/>}/>
|
<Route index element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/dashboard" element={<Navigate to="/console/aws" replace/>}/>
|
<Route path="/dashboard" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/console" element={<Navigate to="/console/aws" replace/>}/>
|
<Route path="/console" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
|
<Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
|
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
||||||
|
<Route path="/diagnostics" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||||
|
<Route path="/diagnostics/azure" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||||
|
<Route path="/diagnostics/gcp" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||||
|
<Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/>
|
||||||
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
|
<Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
|
<Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
|
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
|
||||||
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
|
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
|
||||||
<Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/new" element={<DynamoDbItemEditorPage/>}/>
|
<Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/new" element={<DynamoDbItemEditorPage/>}/>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const apiEndpointKeys = {
|
|||||||
list: "clouds.list",
|
list: "clouds.list",
|
||||||
services: "clouds.services.list",
|
services: "clouds.services.list",
|
||||||
status: "clouds.status.get",
|
status: "clouds.status.get",
|
||||||
|
diagnostics: "clouds.diagnostics.get",
|
||||||
serviceStatus: "clouds.services.status.get",
|
serviceStatus: "clouds.services.status.get",
|
||||||
schema: "clouds.services.schema.get",
|
schema: "clouds.services.schema.get",
|
||||||
dependencies: "clouds.services.dependencies.get",
|
dependencies: "clouds.services.dependencies.get",
|
||||||
@@ -323,6 +324,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.diagnostics,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/diagnostics",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.serviceStatus,
|
apiEndpointKeys.clouds.serviceStatus,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CloudServiceStatus,
|
CloudServiceStatus,
|
||||||
CloudServiceType,
|
CloudServiceType,
|
||||||
CloudStatus,
|
CloudStatus,
|
||||||
|
RuntimeDiagnostics,
|
||||||
} from "@/types/cloud";
|
} from "@/types/cloud";
|
||||||
import type {
|
import type {
|
||||||
AppConfigConfigurationProfile,
|
AppConfigConfigurationProfile,
|
||||||
@@ -86,6 +87,18 @@ export async function getCloudStatus(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCloudDiagnostics(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RuntimeDiagnostics> {
|
||||||
|
const res = await apiClient.call<RuntimeDiagnostics>(
|
||||||
|
apiEndpointKeys.clouds.diagnostics,
|
||||||
|
requestOptions(cloud, "cloud-proxy", { signal }),
|
||||||
|
{ cloud },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCloudServiceStatus(
|
export async function getCloudServiceStatus(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
service: CloudServiceType,
|
service: CloudServiceType,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {useQuery} from '@tanstack/react-query'
|
import {useQuery} from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
getCloudDiagnostics,
|
||||||
getCloudResource,
|
getCloudResource,
|
||||||
getCloudServiceStatus,
|
getCloudServiceStatus,
|
||||||
getCloudStatus,
|
getCloudStatus,
|
||||||
@@ -20,6 +21,7 @@ export const cloudQueryKeys = {
|
|||||||
clouds: ['clouds'] as const,
|
clouds: ['clouds'] as const,
|
||||||
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
|
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
|
||||||
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
|
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
|
||||||
|
diagnostics: (cloud: CloudProvider) => ['cloud-diagnostics', cloud] as const,
|
||||||
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
|
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||||
['cloud-service-status', cloud, service] as const,
|
['cloud-service-status', cloud, service] as const,
|
||||||
resources: (cloud: CloudProvider, service: CloudServiceType) =>
|
resources: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||||
@@ -53,6 +55,15 @@ export function useCloudStatusQuery(cloud: CloudProvider, options: {refetchInter
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Full runtime report; polled slowly because it probes every console service. */
|
||||||
|
export function useCloudDiagnosticsQuery(cloud: CloudProvider) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: cloudQueryKeys.diagnostics(cloud),
|
||||||
|
queryFn: ({signal}) => getCloudDiagnostics(cloud, signal),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Health of the single service being viewed, rather than of the whole cloud. */
|
/** Health of the single service being viewed, rather than of the whole cloud. */
|
||||||
export function useCloudServiceStatusQuery(
|
export function useCloudServiceStatusQuery(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ describe('CloudFormationCreatePanel', () => {
|
|||||||
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
|
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
|
||||||
fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
|
fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
|
fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
|
||||||
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml'})
|
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml', parameters: '{}', capabilities: ''})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ describe('CodeDeployCreatePanel', () => {
|
|||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
await user.type(screen.getByLabelText(/^Application name/), 'orders')
|
||||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
|
||||||
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
|
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
|
||||||
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
||||||
|
|
||||||
expect(screen.queryByLabelText('ECS cluster name')).not.toBeInTheDocument()
|
expect(screen.queryByLabelText(/^Cluster name/)).not.toBeInTheDocument()
|
||||||
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'Lambda', serviceRoleArn: 'role-arn', deploymentConfig: 'AllAtOnce', rollbackOnFailure: true})
|
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'Lambda', serviceRoleArn: 'role-arn', deploymentConfig: 'AllAtOnce', rollbackOnFailure: true})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -24,16 +24,16 @@ describe('CodeDeployCreatePanel', () => {
|
|||||||
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||||
|
|
||||||
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
|
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
|
||||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
await user.type(screen.getByLabelText(/^Application name/), 'orders')
|
||||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
|
||||||
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
|
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
|
||||||
await user.type(screen.getByLabelText('ECS cluster name'), 'orders-cluster')
|
await user.type(screen.getByLabelText(/^Cluster name/), 'orders-cluster')
|
||||||
await user.type(screen.getByLabelText('ECS service name'), 'orders-service')
|
await user.type(screen.getByLabelText(/^Service name/), 'orders-service')
|
||||||
await user.type(screen.getByLabelText('Blue target group name'), 'orders-blue')
|
await user.type(screen.getByLabelText(/Current production target group/), 'orders-blue')
|
||||||
await user.type(screen.getByLabelText('Green target group name'), 'orders-green')
|
await user.type(screen.getByLabelText(/Replacement target group/), 'orders-green')
|
||||||
await user.type(screen.getByLabelText('Production listener ARN'), 'listener-arn')
|
await user.type(screen.getByLabelText(/^Production listener ARN/), 'listener-arn')
|
||||||
await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes')
|
await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes')
|
||||||
await user.click(screen.getByLabelText('Roll back automatically on deployment failure'))
|
await user.click(screen.getByLabelText(/Automatic rollback/))
|
||||||
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
||||||
|
|
||||||
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'role-arn', clusterName: 'orders-cluster', serviceName: 'orders-service', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'listener-arn', deploymentConfig: 'Linear10PercentEvery3Minutes', rollbackOnFailure: false})
|
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'role-arn', clusterName: 'orders-cluster', serviceName: 'orders-service', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'listener-arn', deploymentConfig: 'Linear10PercentEvery3Minutes', rollbackOnFailure: false})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
||||||
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun} from 'lucide-react'
|
import {Activity, AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
|
||||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import theCloud from '../../../../TheCloud.png'
|
import theCloud from '../../../../TheCloud.png'
|
||||||
import {useTheme} from '@/lib/useTheme'
|
import {useTheme} from '@/lib/useTheme'
|
||||||
@@ -157,9 +157,8 @@ function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudS
|
|||||||
export function Layout() {
|
export function Layout() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const activeCloud = activeCloudFromPath(location.pathname)
|
const activeCloud = activeCloudFromPath(location.pathname)
|
||||||
const {theme, toggle} = useTheme()
|
const {theme, setTheme} = useTheme()
|
||||||
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
|
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
|
||||||
const isDark = theme === 'dark'
|
|
||||||
const {data, isError} = useQuery({
|
const {data, isError} = useQuery({
|
||||||
queryKey: ['cloud-status', activeCloud],
|
queryKey: ['cloud-status', activeCloud],
|
||||||
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
|
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
|
||||||
@@ -185,6 +184,7 @@ export function Layout() {
|
|||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
<span className="nav-label">General</span>
|
<span className="nav-label">General</span>
|
||||||
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
||||||
|
<NavItem to={`/diagnostics/${activeCloud}`} icon={<Activity size={18} aria-hidden="true"/>} label="Diagnostics" collapsed={collapsed}/>
|
||||||
</div>
|
</div>
|
||||||
<CloudServiceNav collapsed={collapsed}/>
|
<CloudServiceNav collapsed={collapsed}/>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -209,15 +209,11 @@ export function Layout() {
|
|||||||
<div className="shell">
|
<div className="shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<TopbarSearch/>
|
<TopbarSearch/>
|
||||||
<button
|
<div className="theme-switcher" role="group" aria-label="Console theme">
|
||||||
className="icon-btn"
|
<button className={`icon-btn ${theme === 'dark' ? 'active' : ''}`} type="button" onClick={() => setTheme('dark')} title="Dark theme" aria-label="Dark theme" aria-pressed={theme === 'dark'}><Moon size={14} aria-hidden="true"/></button>
|
||||||
type="button"
|
<button className={`icon-btn ${theme === 'light' ? 'active' : ''}`} type="button" onClick={() => setTheme('light')} title="Light theme" aria-label="Light theme" aria-pressed={theme === 'light'}><Sun size={14} aria-hidden="true"/></button>
|
||||||
onClick={toggle}
|
<button className={`icon-btn ${theme === 'cyber' ? 'active' : ''}`} type="button" onClick={() => setTheme('cyber')} title="Terminal theme" aria-label="Terminal theme" aria-pressed={theme === 'cyber'}><Terminal size={14} aria-hidden="true"/></button>
|
||||||
title={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
</div>
|
||||||
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
|
||||||
>
|
|
||||||
{isDark ? <Sun size={14} aria-hidden="true"/> : <Moon size={14} aria-hidden="true"/>}
|
|
||||||
</button>
|
|
||||||
<div id="topbar-status" className="topbar-status"/>
|
<div id="topbar-status" className="topbar-status"/>
|
||||||
<AccountSwitcher/>
|
<AccountSwitcher/>
|
||||||
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
|
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
|
||||||
@@ -361,6 +357,6 @@ function TopbarSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
|
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
|
||||||
const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/)
|
const match = pathname.match(/^\/(?:cloud-explorer|console|diagnostics)\/(aws|azure|gcp)(?:\/|$)/)
|
||||||
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
|
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {render, screen} from '@testing-library/react'
|
import {render, screen, waitFor} from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
import {expect, test, vi} from 'vitest'
|
import {afterEach, expect, test, vi} from 'vitest'
|
||||||
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
|
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
@@ -14,8 +14,13 @@ const api = vi.hoisted(() => ({
|
|||||||
vi.mock('@/api/cloudProxyClient', () => api)
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
|
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
|
||||||
const user = userEvent.setup()
|
vi.useFakeTimers({shouldAdvanceTime: true})
|
||||||
|
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime})
|
||||||
api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}])
|
api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}])
|
||||||
api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
|
api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
|
||||||
api.putFlatItem.mockResolvedValue({})
|
api.putFlatItem.mockResolvedValue({})
|
||||||
@@ -32,6 +37,10 @@ test('creates and deletes selected ALB HTTP listeners through generic cloud prox
|
|||||||
await user.click(screen.getByRole('button', {name: /create listener/i}))
|
await user.click(screen.getByRole('button', {name: /create listener/i}))
|
||||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'})
|
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'})
|
||||||
|
|
||||||
|
// Creation holds its pending state for a minimum of five seconds, which disables the row actions.
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000)
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', {name: /delete listener 80/i})).toBeEnabled())
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
|
await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
|
||||||
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
|
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ test('shows an object extension instead of a generic object type', async () => {
|
|||||||
expect(await screen.findByText('JSON file')).toBeInTheDocument()
|
expect(await screen.findByText('JSON file')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('offers a copy action for the AWS bucket name', async () => {
|
||||||
|
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: []})
|
||||||
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={[]} onSelectObject={() => {}}/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', {name: 'Copy Bucket name'})).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
test('opens the object action with copy selected', async () => {
|
test('opens the object action with copy selected', async () => {
|
||||||
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
|
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
|
||||||
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
|
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {CapabilitySchema, ObjectActionName} from '@/types/schema'
|
|||||||
import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format'
|
import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format'
|
||||||
import {ImagePreviewModal} from '@/components/ImagePreviewModal'
|
import {ImagePreviewModal} from '@/components/ImagePreviewModal'
|
||||||
import {ObjectThumbnail} from '@/components/ObjectThumbnail'
|
import {ObjectThumbnail} from '@/components/ObjectThumbnail'
|
||||||
|
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||||
|
|
||||||
interface StorageObjectBrowserProps {
|
interface StorageObjectBrowserProps {
|
||||||
cloud: CloudProvider
|
cloud: CloudProvider
|
||||||
@@ -268,7 +269,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
<div className="object-browser-header">
|
<div className="object-browser-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Objects</p>
|
<p className="eyebrow">Objects</p>
|
||||||
<h3>{resource.name}</h3>
|
<h3>{cloud === 'aws' ? <span className="copy-value">{resource.name}<CopyValueButton value={resource.name} label="Bucket name"/></span> : resource.name}</h3>
|
||||||
<div className="object-browser-subtitle">
|
<div className="object-browser-subtitle">
|
||||||
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
|
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
|
||||||
<span>{totalFolders} folders, {totalFiles} {objectLabel}</span>
|
<span>{totalFolders} folders, {totalFiles} {objectLabel}</span>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
|||||||
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
|
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
|
||||||
enabled: Boolean(resource) && runtimeReachable,
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
})
|
})
|
||||||
|
const refetchHealth = healthQuery.refetch
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInstanceId('')
|
setInstanceId('')
|
||||||
@@ -50,10 +51,10 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
void healthQuery.refetch()
|
void refetchHealth()
|
||||||
}, 1_000)
|
}, 1_000)
|
||||||
return () => window.clearInterval(interval)
|
return () => window.clearInterval(interval)
|
||||||
}, [pendingTargets.length, healthQuery.refetch])
|
}, [pendingTargets.length, refetchHealth])
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
||||||
|
|||||||
@@ -218,6 +218,58 @@
|
|||||||
--sidebar-overlay-shadow: 6px 0 28px rgba(38, 48, 82, 0.18);
|
--sidebar-overlay-shadow: 6px 0 28px rgba(38, 48, 82, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="cyber"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--bg: #02060a;
|
||||||
|
--surface: #050d10;
|
||||||
|
--elevated: #071216;
|
||||||
|
--raised: #0a191d;
|
||||||
|
--raised-hover: #10252a;
|
||||||
|
--border: rgba(78, 255, 150, 0.28);
|
||||||
|
--border-faint: rgba(78, 255, 150, 0.16);
|
||||||
|
--border-lighter: rgba(78, 255, 150, 0.08);
|
||||||
|
--border-hover: #ffea00;
|
||||||
|
--active-nav-bg: rgba(78, 255, 150, 0.14);
|
||||||
|
|
||||||
|
--text: #8affb8;
|
||||||
|
--text-bright: #d6ffe6;
|
||||||
|
--text-2: #62d992;
|
||||||
|
--text-3: #2f9a68;
|
||||||
|
--text-muted: var(--text-3);
|
||||||
|
--text-nav: #8affb8;
|
||||||
|
|
||||||
|
--status-ok: #4eff96;
|
||||||
|
--status-warn: #ffea00;
|
||||||
|
--status-error: #ff8da1;
|
||||||
|
|
||||||
|
--accent: #4eff96;
|
||||||
|
--accent-tint: rgba(78, 255, 150, 0.14);
|
||||||
|
--accent-tint-faint: rgba(78, 255, 150, 0.07);
|
||||||
|
--accent-tint-border: rgba(78, 255, 150, 0.34);
|
||||||
|
|
||||||
|
--link: #ffea00;
|
||||||
|
--link-tint: rgba(255, 234, 0, 0.08);
|
||||||
|
--link-tint-hover: rgba(255, 234, 0, 0.14);
|
||||||
|
--link-tint-border: rgba(255, 234, 0, 0.3);
|
||||||
|
|
||||||
|
--hover: rgba(78, 255, 150, 0.06);
|
||||||
|
--hover-md: rgba(78, 255, 150, 0.1);
|
||||||
|
--hover-lg: rgba(78, 255, 150, 0.14);
|
||||||
|
--input-border: rgba(78, 255, 150, 0.42);
|
||||||
|
--input-bg: #030b0d;
|
||||||
|
--connection-bg: #02090c;
|
||||||
|
--kbd-border: rgba(78, 255, 150, 0.35);
|
||||||
|
--scrollbar-thumb: #26784e;
|
||||||
|
--service-card-bg: #050d10;
|
||||||
|
--service-card-hover: #071216;
|
||||||
|
--fn-card-hover: #0a191d;
|
||||||
|
--bulk-bar-bg: #071b13;
|
||||||
|
--bulk-bar-border: #4eff96;
|
||||||
|
--bulk-bar-text: #d6ffe6;
|
||||||
|
--code-bg: #000;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -626,6 +678,26 @@ pre,
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-switcher {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--connection-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher .icon-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher .icon-btn.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(480px, 100%);
|
width: min(480px, 100%);
|
||||||
@@ -4189,6 +4261,104 @@ pre,
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagnostics {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics .table-panel {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-section-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-filters,
|
||||||
|
.diagnostics-filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-filters .input {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-service-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-service {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-page-link {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--link);
|
||||||
|
font-size: 11px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-page-link:hover {
|
||||||
|
border-color: var(--link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-facts div {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-facts dt {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-facts dd {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.diagnostics-filters .input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.console-provider-banner {
|
.console-provider-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
|
|
||||||
type Theme = 'dark' | 'light'
|
export type Theme = 'dark' | 'light' | 'cyber'
|
||||||
|
|
||||||
function getStored(): Theme {
|
function getStored(): Theme {
|
||||||
const v = localStorage.getItem('floci-theme')
|
const v = localStorage.getItem('floci-theme')
|
||||||
return v === 'light' ? 'light' : 'dark'
|
return v === 'light' || v === 'cyber' ? v : 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
@@ -15,7 +15,5 @@ export function useTheme() {
|
|||||||
localStorage.setItem('floci-theme', theme)
|
localStorage.setItem('floci-theme', theme)
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
|
return {theme, setTheme}
|
||||||
|
|
||||||
return {theme, toggle}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {Cloud} from 'lucide-react'
|
import {Cloud} from 'lucide-react'
|
||||||
import {Navigate, useNavigate, useParams, useSearchParams} from 'react-router-dom'
|
import {Navigate, useNavigate, useParams, useSearchParams} from 'react-router-dom'
|
||||||
import {CloudSelector} from '@/components/CloudSelector'
|
|
||||||
import {
|
import {
|
||||||
ProviderBanner,
|
ProviderBanner,
|
||||||
RuntimeFlow,
|
RuntimeFlow,
|
||||||
@@ -39,16 +38,6 @@ export function CloudConsoleHomePage() {
|
|||||||
<p className="muted">Cloud-aware local runtime overview</p>
|
<p className="muted">Cloud-aware local runtime overview</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="cloud-header-selectors">
|
|
||||||
<label>
|
|
||||||
<span>Cloud</span>
|
|
||||||
<CloudSelector
|
|
||||||
clouds={data.cloudsQuery.data ?? []}
|
|
||||||
selected={cloud}
|
|
||||||
onSelect={(nextCloud) => navigate(`/console/${nextCloud}`)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content cloud-console-home">
|
<div className="content cloud-console-home">
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {MemoryRouter, Route, Routes} from 'react-router-dom'
|
||||||
|
import {expect, test, vi} from 'vitest'
|
||||||
|
import {DiagnosticsPage} from './DiagnosticsPage'
|
||||||
|
import type {RuntimeDiagnostics} from '@/types/cloud'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({getCloudDiagnostics: vi.fn()}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
|
||||||
|
const healthy: RuntimeDiagnostics = {
|
||||||
|
cloud: 'aws',
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
runtime: {status: 'reachable', endpoint: 'http://floci:4566', latencyMs: 12, error: null, version: '1.9.0', edition: 'community'},
|
||||||
|
emulatedServices: [
|
||||||
|
{name: 'route53', state: 'running', consolePages: []},
|
||||||
|
{name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]},
|
||||||
|
{name: 'sqs', state: 'stopped', consolePages: [{service: 'messaging', displayName: 'SQS', route: '/cloud-explorer/aws/messaging'}]},
|
||||||
|
],
|
||||||
|
consoleServices: [
|
||||||
|
{cloud: 'aws', service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 4, error: null, errorCode: null},
|
||||||
|
{cloud: 'aws', service: 'messaging', adapterRegistered: true, runtime: 'unavailable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 9, error: 'Service is not running', errorCode: 'RUNTIME_UNAVAILABLE'},
|
||||||
|
],
|
||||||
|
containers: [{kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201}],
|
||||||
|
console: {startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9_015, authMode: 'oidc', activeSessions: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(path = '/diagnostics/aws') {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<Routes><Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/></Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('summarises the runtime and lists failing console services first', async () => {
|
||||||
|
api.getCloudDiagnostics.mockResolvedValue(healthy)
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const summary = await screen.findByRole('region', {name: 'Runtime summary'})
|
||||||
|
expect(within(summary).getByText('Reachable')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('http://floci:4566 · 12 ms')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1.9.0')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1 not running')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const rows = within(screen.getByRole('region', {name: 'Console services'})).getAllByRole('row')
|
||||||
|
expect(rows[1]).toHaveTextContent('messaging')
|
||||||
|
expect(rows[1]).toHaveTextContent('Service is not running (RUNTIME_UNAVAILABLE)')
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', {name: 'i-0abc'})).toHaveAttribute('href', '/cloud-explorer/aws/compute?resource=i-0abc')
|
||||||
|
expect(screen.getByText('localhost:2201')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2 h 30 min')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('OIDC single sign-on')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filters emulated services by console coverage and state', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudDiagnostics.mockResolvedValue(healthy)
|
||||||
|
renderPage()
|
||||||
|
const section = await screen.findByRole('region', {name: 'Emulated services'})
|
||||||
|
|
||||||
|
await user.click(within(section).getByRole('button', {name: 'No console page'}))
|
||||||
|
expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['route53'])
|
||||||
|
|
||||||
|
await user.click(within(section).getByRole('button', {name: 'Not running'}))
|
||||||
|
expect(within(section).getByRole('link', {name: 'SQS'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging')
|
||||||
|
expect(within(section).getAllByRole('listitem')).toHaveLength(1)
|
||||||
|
|
||||||
|
await user.click(within(section).getByRole('button', {name: 'All'}))
|
||||||
|
await user.type(within(section).getByRole('searchbox'), 's3')
|
||||||
|
expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['s3S3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows what is known when the runtime is down', async () => {
|
||||||
|
api.getCloudDiagnostics.mockResolvedValue({
|
||||||
|
...healthy,
|
||||||
|
runtime: {status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null},
|
||||||
|
emulatedServices: null,
|
||||||
|
consoleServices: [],
|
||||||
|
containers: null,
|
||||||
|
} satisfies RuntimeDiagnostics)
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('Unavailable')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Cannot reach Floci core at http://floci:4566')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('Not reported by the runtime')).toHaveLength(2)
|
||||||
|
expect(screen.queryByRole('region', {name: 'Emulated services'})).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('This runtime does not publish container diagnostics.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import {useMemo, useState} from 'react'
|
||||||
|
import {Activity, RefreshCw} from 'lucide-react'
|
||||||
|
import {Link, Navigate, useParams} from 'react-router-dom'
|
||||||
|
import {useCloudDiagnosticsQuery} from '@/api/queries/cloudQueries'
|
||||||
|
import {formatDateTime, formatRelativeTime} from '@/lib/format'
|
||||||
|
import type {CloudProvider, CloudServiceStatus, EmulatedServiceStatus, RuntimeDiagnostics} from '@/types/cloud'
|
||||||
|
|
||||||
|
type ServiceFilter = 'all' | 'not-running' | 'with-page' | 'without-page'
|
||||||
|
|
||||||
|
const SERVICE_FILTERS: Array<{id: ServiceFilter; label: string}> = [
|
||||||
|
{id: 'all', label: 'All'},
|
||||||
|
{id: 'not-running', label: 'Not running'},
|
||||||
|
{id: 'with-page', label: 'Has console page'},
|
||||||
|
{id: 'without-page', label: 'No console page'},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function DiagnosticsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const cloud = normalizeCloud(params.cloud)
|
||||||
|
const query = useCloudDiagnosticsQuery(cloud ?? 'aws')
|
||||||
|
|
||||||
|
if (!cloud) return <Navigate to="/diagnostics/aws" replace/>
|
||||||
|
|
||||||
|
const report = query.data
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header cloud-explorer-header">
|
||||||
|
<div className="page-title">
|
||||||
|
<Activity size={20}/>
|
||||||
|
<div>
|
||||||
|
<h1>Diagnostics</h1>
|
||||||
|
<p className="muted">{report ? `Checked ${formatRelativeTime(report.checkedAt) ?? 'just now'}` : 'Runtime, services and console health'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="button" type="button" disabled={query.isFetching} onClick={() => void query.refetch()}>
|
||||||
|
<RefreshCw size={14}/>{query.isFetching ? 'Checking' : 'Re-check'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content diagnostics">
|
||||||
|
{query.isLoading && <p className="muted padded">Running diagnostics</p>}
|
||||||
|
{query.error && <p className="error-text">Could not load diagnostics: {query.error.message}</p>}
|
||||||
|
{report && <DiagnosticsReport report={report}/>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnosticsReport({report}: {report: RuntimeDiagnostics}) {
|
||||||
|
const emulated = report.emulatedServices
|
||||||
|
const notRunning = emulated?.filter((service) => service.state !== 'running').length ?? 0
|
||||||
|
const covered = emulated?.filter((service) => service.consolePages.length > 0).length ?? 0
|
||||||
|
const reachable = report.runtime.status === 'reachable'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="console-summary" aria-label="Runtime summary">
|
||||||
|
<div className={`summary-tile ${reachable ? 'ready' : 'unavailable'}`}>
|
||||||
|
<span>Runtime</span>
|
||||||
|
<strong>{reachable ? 'Reachable' : 'Unavailable'}</strong>
|
||||||
|
<small>{report.runtime.endpoint ?? 'No endpoint'}{report.runtime.latencyMs !== null ? ` · ${report.runtime.latencyMs} ms` : ''}</small>
|
||||||
|
</div>
|
||||||
|
<div className="summary-tile">
|
||||||
|
<span>Version</span>
|
||||||
|
<strong>{report.runtime.version ?? 'Unknown'}</strong>
|
||||||
|
<small>{report.runtime.edition ? `${report.runtime.edition} edition` : 'Not reported by the runtime'}</small>
|
||||||
|
</div>
|
||||||
|
<div className={`summary-tile ${notRunning > 0 ? 'pending' : ''}`}>
|
||||||
|
<span>Emulated services</span>
|
||||||
|
<strong>{emulated ? emulated.length : '—'}</strong>
|
||||||
|
<small>{emulated ? (notRunning > 0 ? `${notRunning} not running` : 'All running') : 'Not reported by the runtime'}</small>
|
||||||
|
</div>
|
||||||
|
<div className="summary-tile">
|
||||||
|
<span>Console coverage</span>
|
||||||
|
<strong>{emulated ? `${covered} / ${emulated.length}` : `${report.consoleServices.length}`}</strong>
|
||||||
|
<small>{emulated ? 'Emulated services with a console page' : 'Console services'}</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{report.runtime.error && <p className="error-text">{report.runtime.error}</p>}
|
||||||
|
|
||||||
|
<ConsoleServicesTable services={report.consoleServices}/>
|
||||||
|
{emulated && <EmulatedServices services={emulated}/>}
|
||||||
|
<ContainersTable report={report}/>
|
||||||
|
<ConsoleProcess report={report}/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
|
||||||
|
const failing = services.filter((service) => service.runtime !== 'reachable')
|
||||||
|
const ordered = [...failing, ...services.filter((service) => service.runtime === 'reachable')]
|
||||||
|
return (
|
||||||
|
<section className="table-panel" aria-labelledby="diagnostics-console-services">
|
||||||
|
<header className="diagnostics-section-header">
|
||||||
|
<h2 id="diagnostics-console-services">Console services</h2>
|
||||||
|
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `${services.length} responding`}</span>
|
||||||
|
</header>
|
||||||
|
{services.length === 0
|
||||||
|
? <p className="muted padded">No console services are registered for this cloud.</p>
|
||||||
|
: <table className="table">
|
||||||
|
<thead><tr><th>Service</th><th>Status</th><th>Latency</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>{ordered.map((service) => (
|
||||||
|
<tr key={service.service}>
|
||||||
|
<td><code>{service.service}</code></td>
|
||||||
|
<td><span className={`status ${statusClass(service.runtime)}`}>{statusLabel(service.runtime)}</span></td>
|
||||||
|
<td>{service.latencyMs === null ? '—' : `${service.latencyMs} ms`}</td>
|
||||||
|
<td>{service.error ? <span className="error-text compact-text">{service.error}{service.errorCode ? ` (${service.errorCode})` : ''}</span> : <span className="muted">—</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmulatedServices({services}: {services: EmulatedServiceStatus[]}) {
|
||||||
|
const [filter, setFilter] = useState<ServiceFilter>('all')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const term = search.trim().toLowerCase()
|
||||||
|
return services.filter((service) => {
|
||||||
|
if (term && !service.name.includes(term) && !service.consolePages.some((page) => page.displayName.toLowerCase().includes(term))) return false
|
||||||
|
if (filter === 'not-running') return service.state !== 'running'
|
||||||
|
if (filter === 'with-page') return service.consolePages.length > 0
|
||||||
|
if (filter === 'without-page') return service.consolePages.length === 0
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [services, filter, search])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="table-panel" aria-labelledby="diagnostics-emulated-services">
|
||||||
|
<header className="diagnostics-section-header">
|
||||||
|
<h2 id="diagnostics-emulated-services">Emulated services</h2>
|
||||||
|
<div className="diagnostics-filters">
|
||||||
|
<div className="diagnostics-filter-group" role="group" aria-label="Filter emulated services">
|
||||||
|
{SERVICE_FILTERS.map((option) => (
|
||||||
|
<button key={option.id} className={`button compact ${filter === option.id ? 'primary' : ''}`} type="button" aria-pressed={filter === option.id} onClick={() => setFilter(option.id)}>{option.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input className="input" type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Filter services" aria-label="Filter emulated services by name"/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{visible.length === 0
|
||||||
|
? <p className="muted padded">No emulated services match this filter.</p>
|
||||||
|
: <ul className="diagnostics-service-grid">{visible.map((service) => (
|
||||||
|
<li key={service.name} className="diagnostics-service">
|
||||||
|
<span className={`dot ${service.state === 'running' ? 'healthy' : 'unavailable'}`} aria-hidden="true"/>
|
||||||
|
<code>{service.name}</code>
|
||||||
|
{service.state !== 'running' && <span className="muted">{service.state}</span>}
|
||||||
|
{service.consolePages.map((page) => <Link key={page.service} className="diagnostics-page-link" to={page.route}>{page.displayName}</Link>)}
|
||||||
|
</li>
|
||||||
|
))}</ul>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainersTable({report}: {report: RuntimeDiagnostics}) {
|
||||||
|
const containers = report.containers
|
||||||
|
return (
|
||||||
|
<section className="table-panel" aria-labelledby="diagnostics-containers">
|
||||||
|
<header className="diagnostics-section-header">
|
||||||
|
<h2 id="diagnostics-containers">Runtime containers</h2>
|
||||||
|
<span className="muted">Containers started for EC2 instances</span>
|
||||||
|
</header>
|
||||||
|
{containers === null && <p className="muted padded">This runtime does not publish container diagnostics.</p>}
|
||||||
|
{containers?.length === 0 && <p className="muted padded">No EC2 instance containers are running.</p>}
|
||||||
|
{containers && containers.length > 0 && <table className="table">
|
||||||
|
<thead><tr><th>Instance</th><th>Container</th><th>SSH</th></tr></thead>
|
||||||
|
<tbody>{containers.map((container) => (
|
||||||
|
<tr key={container.resourceId}>
|
||||||
|
<td><Link to={`/cloud-explorer/${report.cloud}/compute?resource=${encodeURIComponent(container.resourceId)}`}><code>{container.resourceId}</code></Link></td>
|
||||||
|
<td>{container.status ?? '—'}</td>
|
||||||
|
<td>{container.sshPort ? <code>{container.sshHost ?? 'localhost'}:{container.sshPort}</code> : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsoleProcess({report}: {report: RuntimeDiagnostics}) {
|
||||||
|
return (
|
||||||
|
<section className="table-panel" aria-labelledby="diagnostics-console">
|
||||||
|
<header className="diagnostics-section-header">
|
||||||
|
<h2 id="diagnostics-console">Console</h2>
|
||||||
|
</header>
|
||||||
|
<dl className="diagnostics-facts">
|
||||||
|
<div><dt>Uptime</dt><dd>{formatUptime(report.console.uptimeSeconds)}</dd></div>
|
||||||
|
<div><dt>Started</dt><dd>{formatDateTime(report.console.startedAt) ?? report.console.startedAt}</dd></div>
|
||||||
|
<div><dt>Sign-in</dt><dd>{report.console.authMode === 'oidc' ? 'OIDC single sign-on' : 'Local administrator'}</dd></div>
|
||||||
|
<div><dt>Active sessions</dt><dd>{report.console.activeSessions}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(totalSeconds: number): string {
|
||||||
|
const days = Math.floor(totalSeconds / 86_400)
|
||||||
|
const hours = Math.floor((totalSeconds % 86_400) / 3_600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3_600) / 60)
|
||||||
|
if (days > 0) return `${days} d ${hours} h`
|
||||||
|
if (hours > 0) return `${hours} h ${minutes} min`
|
||||||
|
if (minutes > 0) return `${minutes} min`
|
||||||
|
return `${totalSeconds} s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(runtime: CloudServiceStatus['runtime']): string {
|
||||||
|
if (runtime === 'reachable') return 'healthy'
|
||||||
|
if (runtime === 'coming_soon') return 'degraded'
|
||||||
|
return 'unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(runtime: CloudServiceStatus['runtime']): string {
|
||||||
|
if (runtime === 'reachable') return 'Reachable'
|
||||||
|
if (runtime === 'coming_soon') return 'Not registered'
|
||||||
|
return 'Unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloud(value?: string): CloudProvider | null {
|
||||||
|
return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null
|
||||||
|
}
|
||||||
@@ -83,3 +83,39 @@ export interface CloudServiceStatus {
|
|||||||
/** Mapped error code, so "not implemented" reads differently from "down". */
|
/** Mapped error code, so "not implemented" reads differently from "down". */
|
||||||
errorCode: string | null
|
errorCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmulatedServiceStatus {
|
||||||
|
name: string
|
||||||
|
state: string
|
||||||
|
consolePages: Array<{service: CloudServiceType; displayName: string; route: string}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeContainer {
|
||||||
|
kind: 'ec2-instance'
|
||||||
|
resourceId: string
|
||||||
|
status: string | null
|
||||||
|
sshHost: string | null
|
||||||
|
sshPort: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDiagnostics {
|
||||||
|
cloud: CloudProvider
|
||||||
|
checkedAt: string
|
||||||
|
runtime: {
|
||||||
|
status: RuntimeReachability
|
||||||
|
endpoint: string | null
|
||||||
|
latencyMs: number | null
|
||||||
|
error: string | null
|
||||||
|
version: string | null
|
||||||
|
edition: string | null
|
||||||
|
}
|
||||||
|
emulatedServices: EmulatedServiceStatus[] | null
|
||||||
|
consoleServices: CloudServiceStatus[]
|
||||||
|
containers: RuntimeContainer[] | null
|
||||||
|
console: {
|
||||||
|
startedAt: string
|
||||||
|
uptimeSeconds: number
|
||||||
|
authMode: 'local' | 'oidc'
|
||||||
|
activeSessions: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user