Skip to content

fix: get the dashboard to load on every page - #172

Merged
taoeffect merged 10 commits into
masterfrom
feat/dashboard-live-data
Nov 3, 2026
Merged

taoeffect merged 10 commits into
masterfrom
feat/dashboard-live-data

Conversation

@akhileshthite

@akhileshthite akhileshthite commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Closes #171.

Step 1: it loads on every page

  • Reloading any page but the landing one gave a 404. hono joined the absolute index.html path onto ./, so it was looked up relative to wherever chel was started.
  • The Contracts page crashed on render: vue-clickaway exports mixin, not clickaway.
  • The contract manifest modal never opened: okTurtles.events was never imported.
  • The catch-all route reused the Landing name, which the router warned about. It now redirects to /.
  • Run from source, the server now finds build/dist-dashboard.

Step 2: live data

  • Dashboard: users, contracts and storage used, the newest users, and storage split into contracts and files.
  • Contracts: each contract's type, size, number of messages and the account it belongs to. The filter lists the types on the server, and "view" shows the contract's real manifest.
  • Users: each username with contracts and files owned, space used, share of storage and credits. Deleted accounts stay listed, since their name stays taken.

It only shows names, types, counts and sizes. Contract data is encrypted, and nothing reads it.

Columns the server has no data for are gone: "Created on", "Joined on", "Groups", and the user "view" button that only showed a TODO alert. Accounts and Billing are still mockups.

Two new settings under [server]

  • dashboardListenIP: 127.0.0.1 by default. The dashboard used to listen on server.host (0.0.0.0), so opening it from another machine now needs this.
  • dashboardAdminPassword: no default. Without it the pages load but show no data. With it, the browser asks for it. It can also be set from the environment as server__dashboardAdminPassword.

Credits are stored as picocredits, and I show them as picocredits ÷ 10¹², since chel has no conversion constant. Please check that.

build/dist-dashboard and build/main.js are rebuilt with Deno 2.9.7, and only change where the source did.

Testing: deno task test passes. New tests load every page by URL, and check the password (missing, wrong, right, and from the environment) and the data from a contract registered on the test server. I also ran it against a local chel serving TodoMVC, with three accounts, a shared list and a deleted account.

AI Usage: Claude Opus 5.5 Max
Self review: Approved

Screenshots

image-1791571363078 image-1791571369805 image-1791571375313 image-1791571382217 image-1791571387248

@akhileshthite akhileshthite self-assigned this Oct 8, 2026
@akhileshthite akhileshthite added the enhancement New feature or request label Oct 8, 2026
@akhileshthite

akhileshthite commented Oct 8, 2026 •

Copy link
Copy Markdown
Member Author

/review


AI review started.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Advanced AI Review

  • Type: Agentic (crush)
  • Model: glm-5.3
Click to expand review

Code Review

Base: origin/master (508be9c)
Head: da9cd7d (HEAD)
Date: 2026-10-08
Model: glm-5.3


Scope note: the committed rebuilds of build/main.js and build/dist-dashboard/* are generated artifacts excluded from this diff by default; I spot-checked that the committed bundle matches the source changes (e.g. okturtles.events is present in the rebuilt assets/js/main.js).

I verified the core fixes against the pinned dependency versions rather than taking them on faith:

  • serveStatic fix — checked against hono@4.12.12 (npm:hono/deno + base middleware): path is joined onto root ?? './' via node:path.join. The old absolute path.join(dashboardRoot, 'index.html') was therefore joined onto ./ and made cwd-relative (the 404 bug); the new { root: dashboardRoot, path: 'index.html' } resolves to <dashboardRoot>/index.html. Correct.
  • getDashboardPath fallback — compiled binaries embed the dashboard via deno compile --include, and on Deno ≥ 2.8 node:fs.existsSync sees --include-embedded files at their compile-time paths, so the new existsSync probe does not regress the shipped binaries (repo builds with 2.9.7).
  • vue-clickaway — v2.2.2 exports mixin/directive; v-on-clickaway in Dropdown.vue is supplied by the mixin. Correct, and it is the only usage in the tree.
  • okTurtles.events import — the selectors used by Modal.vue, Prompt.vue, Contracts.vue etc. come from that package; the import was genuinely missing. Correct.
  • Router catch-all — the duplicate Landing route name is gone, and beforeEach still gets a meta.title because the redirect target (/) has one. Correct.

The new test is well constructed (port 0 for an ephemeral port, finally cleanup, response bodies consumed/cancelled so Deno's resource sanitizer stays happy).

No high- or medium-severity issues found. Minor notes:

1. ⚪ getDashboardPath() fallback can return a path outside the project

  • Addressed
  • Dismissed

src/serve/dashboard-server.ts:11-19: the ../../build/dist-dashboard fallback is only correct when import.meta.dirname is src/serve/ (run from source). When running from build/main.js with a missing/incomplete build/dist-dashboard, the fallback resolves to <repo>/../build/dist-dashboard — a path that has never been meaningful. It's harmless today (both candidates are nonexistent and every dashboard route 404s with Not Found), but it makes a broken install harder to diagnose and will surprise anyone extending this function. A candidates-style formulation fails more predictably:

const getDashboardPath = () => {
  const baseDir = import.meta.dirname || path.join(process.cwd(), 'build')
  // build/main.js → build/dist-dashboard; src/serve/… → ../../build/dist-dashboard
  const candidates = [
    path.resolve(baseDir, 'dist-dashboard'),
    path.resolve(baseDir, '../../build/dist-dashboard')
  ]
  return candidates.find((p) => existsSync(p)) ?? candidates[0]
}

2. ⚪ New test fails opaquely when build/dist-dashboard is absent

  • Addressed
  • Dismissed

src/serve/dashboard-server.test.ts:20 starts the server against whatever getDashboardPath() returns, but deno task test never runs deno task build. On the normal fresh checkout the committed build/dist-dashboard covers it, yet after rm -rf build (or in a source-only packaging variant) every step fails with expected index.html / status-404 assertion errors instead of pointing at the actual cause. An explicit guard would make the failure self-explanatory:

const dashboardRoot = path.resolve(import.meta.dirname!, '../../build/dist-dashboard')
// …or export getDashboardPath from dashboard-server.ts and reuse it here
if (!existsSync(dashboardRoot)) {
  throw new Error('build/dist-dashboard is missing — run `deno task build` first')
}

3. ⚪ New test line exceeds the configured deno fmt width

  • Addressed
  • Dismissed

src/serve/dashboard-server.test.ts:19 is 127 characters; deno.json configures fmt.lineWidth: 100. Nothing in the task chain enforces deno fmt, so CI is unaffected, but deno fmt would rewrap this line. Splitting the page list onto its own line keeps the file stable under a future fmt --check gate:

const pages = ['/dashboard', '/dashboard/', '/dashboard/main', '/dashboard/contracts', '/dashboard/no-such-page']
for (const page of pages) {

Review generated using glm-5.3 via Z.AI. Comment /review to re-run.

Comment thread build/main.js Fixed
Comment thread build/main.js Fixed
Comment thread src/serve/dashboard-server.ts Fixed
@akhileshthite

akhileshthite commented Oct 9, 2026 •

Copy link
Copy Markdown
Member Author

/review


AI review started.

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Advanced AI Review

  • Type: Agentic (crush)
  • Model: glm-5.3
Click to expand review

Code Review

Base: origin/master (508be9c)
Head: d6cf8d4 (HEAD, "feat: show live data on the dashboard, behind a password")
Date: 2026-10-09
Model: glm-5.3


Review scope: all 21 source files in the diff (893 changed lines). The generated
build/ artifacts (16 files, +1889/−5283) are excluded from line-by-line review
by the review tooling; spot checks confirmed the committed bundle does contain
the new dashboard and server code (e.g. NoPasswordError in
build/dist-dashboard/assets/js/chunk-*.js, dashboardListenIP in
build/main.js), i.e. it is not stale relative to this change.

Note: Deno is not installed in this CI environment, so the test suite and lint
could not be executed by the reviewer; the review is based on static inspection.

No high- or medium-severity issues were found. The password gate is
well-built: basicAuth is registered first for * (dashboard-server.ts:48-53),
the /api/* 403 fallback for the no-password case runs before the data routes
(dashboard-server.ts:56-59), the catch-all /api 404 is registered after the
data routes (dashboard-server.ts:69), and samePassword hashes both sides
before timingSafeEqual, which avoids leaking password length through timing.
The lower-priority findings follow.

1. ⚪ The CID regex is duplicated from routes.ts and can silently drift

  • Addressed
  • Dismissed

src/serve/dashboard-server.ts:14-15 copies the regex from
src/serve/routes.ts:38 with only a comment ("The same rule routes.ts checks
contract IDs with") tying them together. Nothing enforces that: if the CID
format ever changes in routes.ts (e.g. allowed length or prefix), the
dashboard manifest endpoint would silently start rejecting (or accepting)
different IDs than the rest of the server.

Consider moving the regex to the shared constants module that routes.ts already
pairs with its schemas (src/serve/constants.ts backs both config validation
and runtime reads) and importing it in both places:

// src/serve/constants.ts
export const CID_REGEX = /^z[1-9A-HJ-NP-Za-km-z]{8,72}$/
// src/serve/routes.ts and src/serve/dashboard-server.ts
import { CID_REGEX } from './constants.ts'

2. ⚪ Credits conversion: ÷ 10¹² is the right scale; two small display edges

  • Addressed
  • Dismissed

The PR description asks for this to be checked. Verified:
src/serve/creditsWorker.ts:51-55 defines only
PICOCREDITS_PER_BYTESECOND = BigInt(10); a repo-wide search finds no other
picocredit→credit constant anywhere. The SI "pico-" prefix in the name is the
only anchor, so picocredits / 1e12 in
src/serve/dashboard/views/utils/format.ts:13-15 is the correct and only
consistent conversion. (FWIW, the creditsWorker comment "~1k 'credits' cover
about 100MiB for a year" is loose arithmetic: at rate 10 pc/byte·s that
scenario is 100 MiB × 31,536,000 s × 10 ≈ 3.3e16 pc ≈ 33,000 credits at the
1e12 scale. It is a comment, not a definition, so the ÷1e12 choice stands.)

Two minor display edges in the same helper:

  1. Number(picocredits) loses exactness above 2^53 pc (≈ 9,007 credits). The
    rounding error stays far below the 2-decimal display granularity for any
    plausible balance, so this is cosmetic, but the exact form is just as short:

    export const credits = (picocredits: string): string => {
      const pc = BigInt(picocredits)
      const sign = pc < 0n ? '-' : ''
      const abs = pc < 0n ? -pc : pc
      const whole = abs / 1_000_000_000_000n
      const frac = (abs % 1_000_000_000_000n).toString().padStart(12, '0').slice(0, 2)
      return `${sign}${whole}.${frac}`
    }
  2. With either implementation, a tiny negative balance (between −0.005 and 0
    credits) renders as -0.00 (kept red by the startsWith("-") check at
    src/serve/dashboard/views/pages/Users.vue:45). If that bothers you, clamp
    the formatted value when it rounds to zero.

3. ⚪ No rate limiting on the dashboard password prompt

  • Addressed
  • Dismissed

basicAuth at src/serve/dashboard-server.ts:48-53 accepts unlimited
attempts. With the new 127.0.0.1 default this is fine, but the README
explicitly invites operators to set dashboardListenIP and put the dashboard
behind HTTPS for access from other machines — at which point the only
protection for usernames, storage data and credits is a single password with
no lockout. The main server has a signup rate limiter already
(src/serve/routes.ts consumeSignupToken); a similar lightweight limiter
(per-IP, or simply a short delay on failed verifyUser calls) would be a
cheap follow-up if remote dashboards are expected to be supported.

4. ⚪ The pre-existing is-neautral class typo is propagated into new code

  • Addressed
  • Dismissed

src/serve/dashboard/views/pages/Contracts.vue:52 bakes 'is-neautral' into
the new PILLS array (and line 92 falls back to it). The misspelling comes
from the SCSS map key in
src/serve/dashboard/assets/style/components/_pills.scss:10 (neautral), so
the new code is functionally correct — but each new use makes a future rename
more annoying. Consider a small follow-up renaming the SCSS key to neutral
and updating its uses (Contracts.vue, ChelTypography.vue:42-43).


Review generated using glm-5.3 via Z.AI. Comment /review to re-run.

Comment thread src/serve/dashboard-server.ts Outdated
Comment on lines +17 to +23
const samePassword = (given: Buffer, expected: Buffer): boolean => {
if (given.length !== expected.length) {
timingSafeEqual(expected, expected)
return false
}
return timingSafeEqual(given, expected)
}

@taoeffect taoeffect Oct 14, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is going on here.... This samePassword function can be deleted and timingSafeEqual used directly, right? /cc @corrideat

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timingSafeEqual throws when the two buffers have different lengths ("Input buffers must have the same byte length"), so a password of the wrong length would end up as an error instead of a 401. samePassword just returns false in that case, and compares expected with itself so the wrong-length path takes about the same time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bizarre! Ok thanks @akhileshthite!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is unsafe as written because it leaks information about the password length. As does the conversion from string to buffer, etc.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if there's a standard library function that does this. But see ctEq in chel (src/serve/routes.ts)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can avoid the need for this entirely by hashing expected (with a random salt) and then hashing given with the same salt each time you need to check. Then you could even use standard equality checks (you'd be revealing timing information, but not useful timing information in that case). (or, since at that point you'd have buffers, and they'll have the same size, you can also use timingSafeEqual).

var salt = random(16)
var salted = sha256(concat(salt, password))

// Then
var saltedGiven = sha256(concat(salt, given))
if (!timingSafeEqual(salted, saltedGiven)) throw new Error('Wrong password')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks @corrideat! Both sides are now hashed with a random salt and compared with timingSafeEqual, so samePassword is gone.

@@ -0,0 +1,153 @@
// What the dashboard shows, read from the server's database and from the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@corrideat This file is probably one of the only files that might need your review

@taoeffect taoeffect left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent work @akhileshthite! 👏

My only request for changes are @corrideat's comments regarding the password check

Comment thread src/serve/dashboard-data.ts Outdated
}

const readNumber = async (key: string): Promise<number> =>
Number(await sbp('chelonia.db/get', key)) || 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is fine. But, Number isn't that great here. For example, Number('0x1') (hex), Number('1.1'), etc. are values that it'd take as valid but really ought to be NaN.

value /= 1024
unit++
}
return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${units[unit]}`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string part of this should be handled using Intl.NumberFormat (maybe with a fallback)

corrideat
corrideat previously approved these changes Oct 18, 2026
const cents = (abs % PICOCREDITS_PER_CREDIT) / PICOCREDITS_PER_CENT
const shown = `${whole}.${String(cents).padStart(2, '0')}`
// A balance just below zero shows as 0.00, not -0.00
return balance < 0n && shown !== '0.00' ? `-${shown}` : shown

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same with this. It should use Intl.NumberFormat

Comment thread src/serve/constants.ts Outdated
Comment on lines +13 to +16
// A contract or file ID. Shared by the API and the dashboard so the two can't
// accept different IDs. Checked as safe from ReDoS with
// <https://devina.io/redos-checker>
export const CID_REGEX = /^z[1-9A-HJ-NP-Za-km-z]{8,72}$/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this? In most cases, parsing the CID is the better choice (single point of control)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@corrideat This was removed and maybeParseCID used in src/serve/dashboard-server.ts, but in the latest commit added back as const CID_REGEX = /^z[1-9A-HJ-NP-Za-km-z]{8,72}$/ in routes.ts. What do you think, is it good there or should it also be removed there?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was already in routes.ts before this PR (it's there in 3.4.1). I had only moved it into constants.ts to share it with the dashboard, so once the dashboard switched to maybeParseCID, I put it back as it was. routes.ts is now the same as on master. If we want routes.ts to parse IDs too, I can do that..

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Chelonia apps shouldn't have to know how to do this and that we should have official exports that can be used for fast checks. Opened an issue for this: okTurtles/libcheloniajs#114

Feel free to send a PR for that and in the meantime please reference that issue in a comment here.

@taoeffect

Copy link
Copy Markdown
Member

Note to self: waiting on me to release a libchel so that the CID regex can be used from that

@taoeffect

Copy link
Copy Markdown
Member

@akhileshthite @chelonia/lib@2.1.0 has been published, it contains two new features (both authored by you), could you please incorporate them here in this PR? Then we can merge. 😄

Screenshot 2026-10-31 at 12 48 57 PM

@taoeffect taoeffect left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic work @akhileshthite! 👏 🥳

@taoeffect
taoeffect merged commit 29fc1e1 into master Nov 3, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Connect dashboard to real data

4 participants