Compare commits

..

16 Commits

Author SHA1 Message Date
Zakaria 8b166d86d4 feat(frontend): collapse responding services on the diagnostics page
CI / checks (pull_request) Failing after 12m12s
Failing console services stay visible; responding ones are behind a toggle.
2026-10-23 12:00:10 -04:00
Zakaria f956d977ca fix(frontend): fit the top bar on phone-width screens
Below 640px the search shrinks, the account switcher shows only its icon, and the connection indicator collapses to its status dot with the label kept for screen readers.
2026-10-23 12:00:10 -04:00
Zakaria 9f38313e59 fix(frontend): share one theme setting between the login page and console
The login page stored its theme under thecloud-theme while the console read floci-theme, so a choice made at sign-in was lost. Both now use floci-theme (falling back to the old key once), and useTheme tolerates unavailable storage.
2026-10-23 12:00:10 -04:00
Zakaria ece36e4a34 Merge pull request 'feat: add a runtime diagnostics page' (#6) from feat/diagnostics-page into main
CI / checks (push) Has been cancelled
Reviewed-on: #6
2026-10-23 15:42:29 +00:00
Zakaria 953ba951db feat: add a runtime diagnostics page
CI / checks (pull_request) Failing after 13m18s
Adds GET /api/clouds/:cloud/diagnostics and a Diagnostics page in the
General nav. The report combines, in one call:

- runtime reachability, latency, version and edition from /_floci/health
- every service the runtime emulates, with the console pages backed by it
- per-console-service probes (the existing status check)
- EC2 instance containers from /_floci/diagnose
- console uptime, sign-in mode and active session count

Each source is optional, so a down runtime still yields a partial report.
2026-10-23 11:29:48 -04:00
Zakaria dd16bcf4cd Merge pull request 'test: bring stale tests and lint in line with the current UI' (#5) from chore/ci-green-checks into main
CI / checks (push) Has been cancelled
Reviewed-on: #5
2026-10-23 15:16:18 +00:00
Zakaria 432d9d4ce1 ci: run lint, type-check, tests and build on Gitea Actions
CI / checks (pull_request) Successful in 28m29s
Runs on pushes to main and on every pull request.
2026-10-23 11:12:51 -04:00
Zakaria b171f745db test: bring stale tests and lint in line with the current UI
- CodeDeploy: query the redesigned form's labels.
- CloudFormation: expect the parameters and capabilities fields the form
  now submits.
- ALB listeners: advance the five-second minimum create state before
  deleting, since row actions are disabled while it is pending.
- Lightsail: cast the fetch mock as a whole so it type-checks.
- TargetGroupPanel: depend on the stable refetch function to satisfy
  react-hooks/exhaustive-deps.
2026-10-23 11:12:51 -04:00
Zakaria f5e70a78d8 Merge pull request 'fix(api): block open redirects, add health check and body limits, run as non-root' (#4) from fix/api-hardening into main
Reviewed-on: #4
2026-10-23 14:59:32 +00:00
Zakaria c5a2fc9105 fix(api): block open redirects, add health check and body limits, run as non-root
- safeReturnTo resolves the target against a placeholder origin and rejects
  backslashes and control characters, so `/\evil.example` and similar
  payloads can no longer redirect off-site after sign-in.
- Add a real unauthenticated /api/health and answer unknown /api/* paths
  with a JSON 404 instead of the SPA shell.
- Limit request bodies: 16 KB for /api/auth/* (read before a session
  exists), 128 MB for resource create/update and uploads, 10 MB elsewhere.
- Run the production image as an unprivileged user and add a Docker
  HEALTHCHECK against /api/health.
2026-10-23 10:43:41 -04:00
Zakaria c9b0677fed Merge pull request 'feat(frontend): add a terminal theme and three-way theme switcher' (#3) from feat/console-themes-and-aws-focus into main
Reviewed-on: #3
2026-10-23 14:41:07 +00:00
Zakaria 0371a8884f chore: remove unused index.sample.html 2026-10-23 10:12:41 -04:00
Zakaria 75432a4bc7 feat(s3): add a copy button for the bucket name 2026-10-23 10:12:41 -04:00
Zakaria c519d41799 feat(frontend): focus the console on AWS
Redirects the Azure and GCP console and explorer routes to AWS and removes the cloud selector from Console Home.
2026-10-23 10:12:41 -04:00
Zakaria ba90eff5db feat(frontend): add a terminal theme and three-way theme switcher
Adds the cyber theme to the console and login page, replaces the dark/light toggle with a theme switcher, and moves the login page script to /login.js so it is served under the existing CSP.
2026-10-23 10:12:41 -04:00
Zakaria 502d0b3986 Merge pull request 'fix(api): harden sign-in throttling, OIDC authorization and auth state' (#2) from fix/auth-hardening into main
Reviewed-on: #2
2026-10-23 14:01:46 +00:00
32 changed files with 1284 additions and 235 deletions
+46
View File
@@ -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
View File
@@ -23,13 +23,19 @@ RUN bun build --compile --minify src/index.ts --outfile server
# Stage 3 — minimal runtime (no bun, no node_modules)
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
COPY --from=api-build /app/server ./server
COPY --from=frontend-build /app/packages/frontend/dist ./public
COPY packages/api/public ./public
COPY TheCloud.png ./public/TheCloud.png
COPY svgAsset/authentik.svg ./public/authentik.svg
COPY aws-icons-svg ./public/aws-icons
ENV PORT=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"]
-176
View File
@@ -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>
+114
View File
@@ -0,0 +1,114 @@
(() => {
// Shared with the console (useTheme.ts, index.html) so one choice applies everywhere.
const THEME_KEY = 'floci-theme';
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(THEME_KEY, 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(THEME_KEY) ?? 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 () => {
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
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'},
]}))
]}))) as unknown as typeof fetch
try {
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
expect(resource?.metadata.hostPortMappings).toEqual([
+21
View File
@@ -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', () => {
let privateKey: CryptoKey
let jwk: JWK
+15 -3
View File
@@ -288,9 +288,21 @@ function loginErrorMessage(code: string | undefined): string | null {
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 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) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[character] ?? character)) }
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) }
@@ -336,4 +348,4 @@ function parseTrustedProxies(value: string | undefined): BlockList | null {
}
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') }
+42
View File
@@ -77,6 +77,48 @@ export interface CloudServiceStatus {
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 interface FieldSchema {
+48
View File
@@ -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)
})
})
+22
View File
@@ -1,6 +1,7 @@
import "dotenv/config";
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { bodyLimit } from "hono/body-limit";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import {authConfig, authMiddleware, authRoutes} from './auth';
@@ -44,9 +45,19 @@ app.use(
}),
);
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("*", authRoutes(authentication));
app.get("/api/health", (c) => c.json({ status: "ok" }));
app.route("/api/eks", eks);
app.route("/api/ec2", ec2);
app.route("/api/secretsmanager", secretsmanager);
@@ -55,6 +66,7 @@ app.route("/api/msk", msk);
app.route("/api/msk-connect", mskConnect);
app.route("/api/ses", ses);
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)
app.use("*", serveStatic({ root: "./public" }));
@@ -63,6 +75,16 @@ app.get("*", serveStatic({ path: "./public/index.html" }));
const port = Number(process.env.PORT ?? 4501);
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 {
return (
path.startsWith("/api/secretsmanager") ||
+2 -1
View File
@@ -19,6 +19,7 @@ export function renderLoginPage(options: LoginPageOptions): string {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<link rel="icon" href="/TheCloud.png" />
<title>Sign in · TheCloud</title>
<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}
@@ -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> 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>
<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>
</html>`
}
+7
View File
@@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
import {serviceForAccount} from '../cloudProxy'
import {CloudProxyService} from '../service/CloudProxyService'
import {runtimeDiagnostics} from '../service/runtimeDiagnostics'
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
// 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}))
})
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) => {
const cloud = c.req.param('cloud') as CloudProvider
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
}
+1 -1
View File
@@ -10,7 +10,7 @@
var t = localStorage.getItem("floci-theme");
document.documentElement.setAttribute(
"data-theme",
t === "light" ? "light" : "dark",
t === "light" || t === "cyber" ? t : "dark",
);
})();
</script>
+9
View File
@@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage'
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
import {DiagnosticsPage} from '@/pages/DiagnosticsPage'
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
@@ -15,8 +16,16 @@ export default function App() {
<Route index 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/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="/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/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/:resourceId/data" element={<DatabaseDataPage/>}/>
<Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/new" element={<DynamoDbItemEditorPage/>}/>
+8
View File
@@ -13,6 +13,7 @@ export const apiEndpointKeys = {
list: "clouds.list",
services: "clouds.services.list",
status: "clouds.status.get",
diagnostics: "clouds.diagnostics.get",
serviceStatus: "clouds.services.status.get",
schema: "clouds.services.schema.get",
dependencies: "clouds.services.dependencies.get",
@@ -323,6 +324,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "GET",
},
],
[
apiEndpointKeys.clouds.diagnostics,
{
path: "/clouds/:cloud/diagnostics",
method: "GET",
},
],
[
apiEndpointKeys.clouds.serviceStatus,
{
@@ -6,6 +6,7 @@ import type {
CloudServiceStatus,
CloudServiceType,
CloudStatus,
RuntimeDiagnostics,
} from "@/types/cloud";
import type {
AppConfigConfigurationProfile,
@@ -86,6 +87,18 @@ export async function getCloudStatus(
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(
cloud: CloudProvider,
service: CloudServiceType,
@@ -1,5 +1,6 @@
import {useQuery} from '@tanstack/react-query'
import {
getCloudDiagnostics,
getCloudResource,
getCloudServiceStatus,
getCloudStatus,
@@ -20,6 +21,7 @@ export const cloudQueryKeys = {
clouds: ['clouds'] as const,
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
diagnostics: (cloud: CloudProvider) => ['cloud-diagnostics', cloud] as const,
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
['cloud-service-status', cloud, service] as const,
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. */
export function useCloudServiceStatusQuery(
cloud: CloudProvider,
@@ -17,6 +17,6 @@ describe('CloudFormationCreatePanel', () => {
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.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()
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
await user.type(screen.getByLabelText('Application name'), 'orders')
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(/^Application name/), 'orders')
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
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})
})
@@ -24,16 +24,16 @@ describe('CodeDeployCreatePanel', () => {
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
await user.type(screen.getByLabelText('Application name'), 'orders')
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('ECS cluster name'), 'orders-cluster')
await user.type(screen.getByLabelText('ECS service name'), 'orders-service')
await user.type(screen.getByLabelText('Blue target group name'), 'orders-blue')
await user.type(screen.getByLabelText('Green target group name'), 'orders-green')
await user.type(screen.getByLabelText('Production listener ARN'), 'listener-arn')
await user.type(screen.getByLabelText(/^Application name/), 'orders')
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
await user.type(screen.getByLabelText(/^Cluster name/), 'orders-cluster')
await user.type(screen.getByLabelText(/^Service name/), 'orders-service')
await user.type(screen.getByLabelText(/Current production target group/), 'orders-blue')
await user.type(screen.getByLabelText(/Replacement target group/), 'orders-green')
await user.type(screen.getByLabelText(/^Production listener ARN/), 'listener-arn')
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'}))
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})
+9 -13
View File
@@ -1,5 +1,5 @@
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 theCloud from '../../../../TheCloud.png'
import {useTheme} from '@/lib/useTheme'
@@ -157,9 +157,8 @@ function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudS
export function Layout() {
const location = useLocation()
const activeCloud = activeCloudFromPath(location.pathname)
const {theme, toggle} = useTheme()
const {theme, setTheme} = useTheme()
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
const isDark = theme === 'dark'
const {data, isError} = useQuery({
queryKey: ['cloud-status', activeCloud],
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
@@ -185,6 +184,7 @@ export function Layout() {
<div className="nav-section">
<span className="nav-label">General</span>
<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>
<CloudServiceNav collapsed={collapsed}/>
</nav>
@@ -209,15 +209,11 @@ export function Layout() {
<div className="shell">
<header className="topbar">
<TopbarSearch/>
<button
className="icon-btn"
type="button"
onClick={toggle}
title={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
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 className="theme-switcher" role="group" aria-label="Console theme">
<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>
<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>
<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>
</div>
<div id="topbar-status" className="topbar-status"/>
<AccountSwitcher/>
<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' {
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'
}
@@ -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 {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {expect, test, vi} from 'vitest'
import {afterEach, expect, test, vi} from 'vitest'
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
const api = vi.hoisted(() => ({
@@ -14,8 +14,13 @@ const api = vi.hoisted(() => ({
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
afterEach(() => {
vi.useRealTimers()
})
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.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
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}))
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}))
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()
})
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 () => {
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
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 {ImagePreviewModal} from '@/components/ImagePreviewModal'
import {ObjectThumbnail} from '@/components/ObjectThumbnail'
import {CopyValueButton} from '@/components/CopyValueButton'
interface StorageObjectBrowserProps {
cloud: CloudProvider
@@ -268,7 +269,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
<div className="object-browser-header">
<div>
<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">
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
<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),
enabled: Boolean(resource) && runtimeReachable,
})
const refetchHealth = healthQuery.refetch
useEffect(() => {
setInstanceId('')
@@ -50,10 +51,10 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
return
}
const interval = window.setInterval(() => {
void healthQuery.refetch()
void refetchHealth()
}, 1_000)
return () => window.clearInterval(interval)
}, [pendingTargets.length, healthQuery.refetch])
}, [pendingTargets.length, refetchHealth])
const refresh = () => {
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
+211
View File
@@ -218,6 +218,58 @@
--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;
}
@@ -626,6 +678,26 @@ pre,
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 {
position: relative;
width: min(480px, 100%);
@@ -707,6 +779,47 @@ pre,
position: relative;
}
@media (max-width: 640px) {
.topbar {
gap: 8px;
padding: 0 10px;
}
.topbar .search {
flex: 1 1 auto;
min-width: 0;
}
.topbar .search input {
padding-right: 8px;
}
.topbar .kbd,
.topbar .account-meta,
.topbar .connection-target {
display: none;
}
.topbar .connection-state {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.topbar .connection {
flex: 0 0 auto;
padding: 8px;
}
.topbar .account-trigger {
gap: 4px;
padding: 0 6px;
}
}
.topbar-status:empty + .account-switcher {
margin-left: auto;
}
@@ -4189,6 +4302,104 @@ pre,
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 {
display: flex;
align-items: center;
+17 -7
View File
@@ -1,10 +1,18 @@
import {useEffect, useState} from 'react'
type Theme = 'dark' | 'light'
export type Theme = 'dark' | 'light' | 'cyber'
/** Shared with the login page (public/login.js) and index.html. */
const THEME_KEY = 'floci-theme'
function getStored(): Theme {
const v = localStorage.getItem('floci-theme')
return v === 'light' ? 'light' : 'dark'
try {
const v = localStorage.getItem(THEME_KEY)
return v === 'light' || v === 'cyber' ? v : 'dark'
} catch {
// localStorage unavailable (private mode) — fall back to the default theme.
return 'dark'
}
}
export function useTheme() {
@@ -12,10 +20,12 @@ export function useTheme() {
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('floci-theme', theme)
try {
localStorage.setItem(THEME_KEY, theme)
} catch {
// Persisting the choice is optional when storage is unavailable.
}
}, [theme])
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
return {theme, toggle}
return {theme, setTheme}
}
@@ -1,6 +1,5 @@
import {Cloud} from 'lucide-react'
import {Navigate, useNavigate, useParams, useSearchParams} from 'react-router-dom'
import {CloudSelector} from '@/components/CloudSelector'
import {
ProviderBanner,
RuntimeFlow,
@@ -39,16 +38,6 @@ export function CloudConsoleHomePage() {
<p className="muted">Cloud-aware local runtime overview</p>
</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 className="content cloud-console-home">
@@ -0,0 +1,116 @@
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 consoleServices = screen.getByRole('region', {name: 'Console services'})
const rows = within(consoleServices).getAllByRole('row')
expect(rows).toHaveLength(2)
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('keeps responding console services behind a toggle', async () => {
const user = userEvent.setup()
api.getCloudDiagnostics.mockResolvedValue({
...healthy,
consoleServices: healthy.consoleServices.map((service) => ({...service, runtime: 'reachable', error: null, errorCode: null})),
} satisfies RuntimeDiagnostics)
renderPage()
const consoleServices = await screen.findByRole('region', {name: 'Console services'})
expect(within(consoleServices).getByText('All 2 responding')).toBeInTheDocument()
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
await user.click(within(consoleServices).getByRole('button', {name: 'Show 2 responding'}))
expect(within(consoleServices).getAllByRole('row')).toHaveLength(3)
await user.click(within(consoleServices).getByRole('button', {name: 'Hide responding'}))
expect(within(consoleServices).queryByRole('table')).not.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,229 @@
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 [showResponding, setShowResponding] = useState(false)
const failing = services.filter((service) => service.runtime !== 'reachable')
const responding = services.filter((service) => service.runtime === 'reachable')
const ordered = showResponding ? [...failing, ...responding] : failing
return (
<section className="table-panel" aria-labelledby="diagnostics-console-services">
<header className="diagnostics-section-header">
<h2 id="diagnostics-console-services">Console services</h2>
<div className="diagnostics-filters">
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `All ${services.length} responding`}</span>
{responding.length > 0 && (
<button className="button compact" type="button" aria-expanded={showResponding} onClick={() => setShowResponding((value) => !value)}>
{showResponding ? 'Hide responding' : `Show ${responding.length} responding`}
</button>
)}
</div>
</header>
{services.length === 0 && <p className="muted padded">No console services are registered for this cloud.</p>}
{ordered.length > 0 && <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
}
+36
View File
@@ -83,3 +83,39 @@ export interface CloudServiceStatus {
/** Mapped error code, so "not implemented" reads differently from "down". */
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
}
}