Compare commits
4
Commits
c5e30a4785
...
2b95be3091
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b95be3091 | ||
|
|
0374f9f4c2 | ||
|
|
205ad35c70 | ||
|
|
0b3a86aad2 |
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# Salin ke `.env` lalu sesuaikan. Semua opsional — default di `config.py`.
|
||||
# Nilai dari env shell (export) juga didahulukan lewat os.getenv.
|
||||
|
||||
# Akun Block Producer yang dicari
|
||||
VEX_TARGET_BP=databisnisid
|
||||
|
||||
# Node RPC publik Vexanium (ganti bila node utama lambat/timeout)
|
||||
VEX_API_NODE=https://v2.vexascan.com:2096
|
||||
|
||||
# Lokasi file SQLite (disarankan absolut bila dijadwalkan via cron)
|
||||
VEX_DB_PATH=voters.db
|
||||
|
||||
# Syarat minimum stake (VEX) agar pemilih disimpan
|
||||
VEX_MIN_STAKED_VEX=1000
|
||||
|
||||
# Dashboard: jumlah baris per halaman
|
||||
DASH_PAGE_SIZE=50
|
||||
|
||||
# Dashboard: host & port server
|
||||
DASH_HOST=127.0.0.1
|
||||
DASH_PORT=5000
|
||||
|
||||
# Dashboard: jumlah worker gunicorn (produksi)
|
||||
DASH_WORKERS=2
|
||||
@@ -4,5 +4,8 @@ scan.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# local env config (secrets/overrides — jangan di-commit)
|
||||
.env
|
||||
|
||||
# virtual env (deps installable via requirements.txt)
|
||||
venv/
|
||||
@@ -5,13 +5,15 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
|
||||
## Run
|
||||
|
||||
- Node reference script: `node get_voters.js` (no deps, Node 18+).
|
||||
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, dep `requests` only (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
|
||||
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile get_voters.py`.
|
||||
- Test (the verification oracle): `./venv/bin/python test_get_voters.py` must exit 0. It mocks the network — the live node is too flaky/slow for a full-scan test. Run it after touching `get_voters.py`.
|
||||
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
|
||||
- Web dashboard (read-only, reads `voters.db`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans.
|
||||
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
|
||||
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py`.
|
||||
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py` and `./venv/bin/python test_dashboard.py` must both exit 0. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
|
||||
|
||||
## Key facts
|
||||
|
||||
- `TARGET_BP` (`databisnisid`) and `API_NODE` (`https://v2.vexascan.com:2096`) are hardcoded constants at the top of both scripts.
|
||||
- `TARGET_BP` (`databisnisid`) and `API_NODE` (`https://v2.vexascan.com:2096`) are the defaults in `config.py` (env-overridable); the JS reference still hardcodes them at the top of the file.
|
||||
- The public Vexanium RPC node is flaky/timeout-prone; both scripts have retry logic built in — don't remove or bypass it. A full scan of the voters table takes minutes.
|
||||
- The system contract on Vexanium is `vexcore` (not `vexio`), scope is also `vexcore`. Do not "fix" this to match EOS convention.
|
||||
- VEX stake is stored scaled by 10000; the Python tool divides by 10000 before storing.
|
||||
@@ -24,10 +26,14 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
|
||||
- `SPEC.md` — spec (goal/constraints/interfaces/invariants/tasks/bug log), in caveman encoding. Build/backprop flow through it.
|
||||
- `get_voters.py` — production fetcher: scan → filter → SQLite `voters.db` (each run replaces the table = daily snapshot, with `scanned_at`; never appends history).
|
||||
- `get_voters.js` — reference implementation only.
|
||||
- `dashboard.py` + `templates/index.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads `voters.db`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off.
|
||||
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`; shared by get_voters & dashboard.
|
||||
- `DESIGN.md` — Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono).
|
||||
- `voters.db` — SQLite output (daily snapshot, gitignored in spirit).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Comments and console output are in Indonesian — keep new output/comments in Indonesian.
|
||||
- Dashboard UI labels are Indonesian uppercase captions (e.g. "DAFTAR PEMILIH", "TOTAL PEMILIH").
|
||||
- JS style: 2-space indent, semicolons, single quotes, trailing commas, async/await.
|
||||
- Python style: 4-space indent, stdlib `sqlite3`, `requests`, PEP8.
|
||||
- Python style: 4-space indent, stdlib `sqlite3`, `requests`, `flask`, PEP8.
|
||||
@@ -0,0 +1,454 @@
|
||||
---
|
||||
version: alpha
|
||||
name: Bugatti-design-analysis
|
||||
description: An austere luxury-automotive interface that uses near-pure black canvas, white uppercase letterspaced display, and full-bleed automotive photography as the only voltage. The system runs three custom Bugatti typefaces — Bugatti Display, Bugatti Text Regular, and Bugatti Monospace — and combines them at modest weights with wide tracking to feel European-engineered, hyper-minimal, and quietly expensive. There is no accent color, no decorative element, no chrome — only photography, typography, and the brand wordmark.
|
||||
|
||||
colors:
|
||||
primary: "#ffffff"
|
||||
ink: "#ffffff"
|
||||
body: "#cccccc"
|
||||
body-strong: "#e6e6e6"
|
||||
muted: "#999999"
|
||||
muted-soft: "#666666"
|
||||
hairline: "#262626"
|
||||
hairline-strong: "#3a3a3a"
|
||||
canvas: "#000000"
|
||||
surface-soft: "#0d0d0d"
|
||||
surface-card: "#141414"
|
||||
surface-elevated: "#1f1f1f"
|
||||
on-primary: "#000000"
|
||||
on-dark: "#ffffff"
|
||||
on-photo: "#ffffff"
|
||||
link: "#c3d9f3"
|
||||
warning: "#d4a017"
|
||||
success: "#5fa657"
|
||||
|
||||
typography:
|
||||
display-xl:
|
||||
fontFamily: "Bugatti Display, sans-serif"
|
||||
fontSize: 64px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.1
|
||||
letterSpacing: 4px
|
||||
display-lg:
|
||||
fontFamily: "Bugatti Display, sans-serif"
|
||||
fontSize: 48px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.15
|
||||
letterSpacing: 3px
|
||||
display-md:
|
||||
fontFamily: "Bugatti Display, sans-serif"
|
||||
fontSize: 32px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.2
|
||||
letterSpacing: 2px
|
||||
display-sm:
|
||||
fontFamily: "Bugatti Display, sans-serif"
|
||||
fontSize: 24px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.3
|
||||
letterSpacing: 1.5px
|
||||
wordmark:
|
||||
fontFamily: "Bugatti Display, serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1
|
||||
letterSpacing: 6px
|
||||
title-md:
|
||||
fontFamily: "Bugatti Display, sans-serif"
|
||||
fontSize: 20px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.3
|
||||
letterSpacing: 1px
|
||||
title-sm:
|
||||
fontFamily: "Bugatti Display, sans-serif"
|
||||
fontSize: 16px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.3
|
||||
letterSpacing: 1.5px
|
||||
caption-uppercase:
|
||||
fontFamily: "Bugatti Monospace, ui-monospace, monospace"
|
||||
fontSize: 11px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.4
|
||||
letterSpacing: 2px
|
||||
body-md:
|
||||
fontFamily: "Bugatti Text Regular, serif"
|
||||
fontSize: 16px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
letterSpacing: 0
|
||||
body-sm:
|
||||
fontFamily: "Bugatti Text Regular, serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
letterSpacing: 0
|
||||
button:
|
||||
fontFamily: "Bugatti Monospace, ui-monospace, monospace"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1
|
||||
letterSpacing: 2.5px
|
||||
nav-link:
|
||||
fontFamily: "Bugatti Monospace, ui-monospace, monospace"
|
||||
fontSize: 12px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.4
|
||||
letterSpacing: 2px
|
||||
|
||||
rounded:
|
||||
none: 0px
|
||||
pill: 9999px
|
||||
full: 9999px
|
||||
|
||||
spacing:
|
||||
xxs: 4px
|
||||
xs: 8px
|
||||
sm: 12px
|
||||
md: 16px
|
||||
lg: 24px
|
||||
xl: 40px
|
||||
xxl: 64px
|
||||
section: 120px
|
||||
|
||||
components:
|
||||
button-primary:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.button}"
|
||||
rounded: "{rounded.pill}"
|
||||
padding: 14px 32px
|
||||
height: 44px
|
||||
button-icon:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.on-dark}"
|
||||
rounded: "{rounded.full}"
|
||||
size: 40px
|
||||
text-link:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.link}"
|
||||
typography: "{typography.button}"
|
||||
top-nav:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.nav-link}"
|
||||
height: 56px
|
||||
wordmark-display:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.wordmark}"
|
||||
hero-photo-band:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.display-xl}"
|
||||
padding: 96px
|
||||
caption-overlay:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.caption-uppercase}"
|
||||
career-callout-card:
|
||||
backgroundColor: "{colors.surface-card}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.body-sm}"
|
||||
rounded: "{rounded.none}"
|
||||
padding: 16px
|
||||
width: 320px
|
||||
model-photo-card:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.display-md}"
|
||||
rounded: "{rounded.none}"
|
||||
newsroom-article-card:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.title-md}"
|
||||
rounded: "{rounded.none}"
|
||||
padding: 24px
|
||||
career-listing-row:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.title-md}"
|
||||
padding: 24px 0
|
||||
text-input:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.body-md}"
|
||||
rounded: "{rounded.none}"
|
||||
padding: 12px 0
|
||||
height: 44px
|
||||
spec-cell:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.title-md}"
|
||||
padding: 24px 0
|
||||
date-pill:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.muted}"
|
||||
typography: "{typography.caption-uppercase}"
|
||||
category-tag:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.muted}"
|
||||
typography: "{typography.caption-uppercase}"
|
||||
cta-band-photo:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.display-md}"
|
||||
padding: 80px
|
||||
footer:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.muted}"
|
||||
typography: "{typography.body-sm}"
|
||||
padding: 64px
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Bugatti's marketing surface is the most austere interface in luxury automotive: a near-pure black canvas (`{colors.canvas}` — #000000) holding white uppercase **letterspaced** display type and full-bleed automotive photography. The system has no accent color, no surface card decoration, no shadows, no gradients, no chrome — only **photography, typography, and the brand wordmark**. Every other luxury auto site in this category (BMW M, Aston Martin, Lamborghini) uses some form of accent color or signature element; Bugatti uses nothing. The empty space, the photograph, and the precisely-tracked Bugatti Display headline ARE the brand.
|
||||
|
||||
The system runs **three custom Bugatti typefaces**: **Bugatti Display** (display headlines, the "BUGATTI" wordmark, all caps with wide tracking), **Bugatti Text Regular** (body paragraphs, a serif text face), and **Bugatti Monospace** (button labels, navigation, captions, dates — anywhere precision and machined feel matters). The split is deliberate and unbreakable: never use Bugatti Text in a button, never use Bugatti Monospace in a paragraph.
|
||||
|
||||
Display sizes use weight 400 (regular) — never bold. Visual emphasis comes from **size and tracking**, not weight. Letter-spacing on the wordmark is 6px; on display headlines 2-4px; on uppercase labels 2-2.5px. Tight tracking is a brand violation. The wide spacing creates the "engineered precision" feel that no other luxury maker matches.
|
||||
|
||||
**Key Characteristics:**
|
||||
- Pure black canvas (`{colors.canvas}` — #000000) with white type. The system does not have a light mode.
|
||||
- Three custom Bugatti typefaces: **Display** (uppercase headlines + wordmark), **Text Regular** (body serif), **Monospace** (buttons, captions, nav).
|
||||
- All display headlines are UPPERCASE with wide letter-spacing (2-4px). Body copy stays sentence-case at standard tracking.
|
||||
- No accent color. The only non-monochrome color anywhere on the site is `{colors.link}` (#c3d9f3) — a desaturated ice-blue used on inline anchor links, and even that appears rarely.
|
||||
- Buttons are pill-shaped (`{rounded.pill}`) with **transparent background** and a 1px white outline. Bugatti is the only luxury-auto brand whose primary CTA is fully transparent.
|
||||
- Photography is the only depth element. No drop shadows. No gradients. No card surfaces. Surface cards are `{colors.surface-card}` (#141414) at most — a barely-different-from-black tone.
|
||||
- Section rhythm is generous — `{spacing.section}` (120px) between major bands, longer than most marketing sites because Bugatti's pages are mostly photography with minimal text density.
|
||||
|
||||
## Colors
|
||||
|
||||
### Brand & Accent
|
||||
- **Primary** (`{colors.primary}` — #ffffff): The single brand color. White type and white CTA outlines on the black canvas.
|
||||
- **Link** (`{colors.link}` — #c3d9f3): The only non-monochrome color in the system — a desaturated ice-blue used on inline anchor links and rarely on focus states. Bugatti's brand discipline is so tight that this single token is essentially the entire chromatic vocabulary outside black-and-white.
|
||||
|
||||
### Surface
|
||||
- **Canvas** (`{colors.canvas}` — #000000): The default page floor across every surface. Pure black.
|
||||
- **Surface Soft** (`{colors.surface-soft}` — #0d0d0d): A barely-different-from-black tone used for spec table rows and dense data sections.
|
||||
- **Surface Card** (`{colors.surface-card}` — #141414): Cards (career callout, newsroom article container, occasional content cards). Even card surfaces stay nearly-black — no contrast jump.
|
||||
- **Surface Elevated** (`{colors.surface-elevated}` — #1f1f1f): One step further from black, used for nested cards on rare dense pages.
|
||||
- **Hairline** (`{colors.hairline}` — #262626): The 1px divider tone. Visible but quiet. Used on table rows, between body sections, around card outlines.
|
||||
- **Hairline Strong** (`{colors.hairline-strong}` — #3a3a3a): A heavier divider used on the underside of input fields (input fields have no border — only an underline hairline).
|
||||
|
||||
### Text
|
||||
- **Ink / On Dark** (`{colors.on-dark}` — #ffffff): All headline and primary text on dark canvas.
|
||||
- **Body** (`{colors.body}` — #cccccc): Default running-text color (slightly cooler than pure white). Used in body paragraphs.
|
||||
- **Body Strong** (`{colors.body-strong}` — #e6e6e6): Emphasized body / lead paragraph.
|
||||
- **Muted** (`{colors.muted}` — #999999): Footer links, dates, captions, secondary metadata. Dembrandt's frequency analysis confirms this as palette-2 (count 6, medium confidence).
|
||||
- **Muted Soft** (`{colors.muted-soft}` — #666666): A second-tier muted for very-secondary text (legal disclaimer, copyright line).
|
||||
|
||||
### Semantic
|
||||
- **Warning** (`{colors.warning}` — #d4a017): Reserved for technical-warning callouts (specifications, recall notices). Almost never appears on marketing surfaces.
|
||||
- **Success** (`{colors.success}` — #5fa657): Order confirmation states (rare on marketing pages).
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Family
|
||||
The system runs **three custom Bugatti typefaces** as a rigid trinity:
|
||||
1. **Bugatti Display** — All display headlines (h1, h2, h3), the "BUGATTI" wordmark, model name plates. Uppercase, wide-tracked. The default for any visual emphasis.
|
||||
2. **Bugatti Text Regular** — A serif text face used exclusively for running body copy, lead paragraphs, model descriptions. Standard sentence-case, no letter-spacing.
|
||||
3. **Bugatti Monospace** — Button labels, navigation, captions, dates, monospace-precision contexts. Always uppercase with 2-2.5px tracking.
|
||||
|
||||
The split is functional and absolute. Bugatti Display in a button breaks the "machined precision" voice; Bugatti Monospace in a paragraph breaks the "engineered elegance" voice; Bugatti Text in a button is unthinkable.
|
||||
|
||||
The fallback stack walks `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` for Bugatti Display, `Garamond, "Times New Roman", serif` for Bugatti Text Regular, and `ui-monospace, "SF Mono", "Cascadia Mono", monospace` for Bugatti Monospace.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
||||
|---|---|---|---|---|---|
|
||||
| `{typography.display-xl}` | 64px | 400 | 1.1 | 4px | Hero h1 ("THE BUGATTI F.K.P. HOMMAGE", "TOURBILLON") — Bugatti Display, uppercase, wide-tracked |
|
||||
| `{typography.display-lg}` | 48px | 400 | 1.15 | 3px | Section heads — Bugatti Display, uppercase |
|
||||
| `{typography.display-md}` | 32px | 400 | 1.2 | 2px | Sub-section heads, model names — Bugatti Display |
|
||||
| `{typography.display-sm}` | 24px | 400 | 1.3 | 1.5px | Card titles — Bugatti Display |
|
||||
| `{typography.wordmark}` | 14px | 400 | 1.0 | 6px | The "BUGATTI" brand wordmark in the top nav — Bugatti Display, the widest tracking in the system |
|
||||
| `{typography.title-md}` | 20px | 400 | 1.3 | 1px | Career listing titles, intro paragraphs — Bugatti Display |
|
||||
| `{typography.title-sm}` | 16px | 400 | 1.3 | 1.5px | Mid-tier headlines, callout cards |
|
||||
| `{typography.caption-uppercase}` | 11px | 400 | 1.4 | 2px | Photo captions, metadata, "EXPLORE OUR OPPORTUNITIES" — Bugatti Monospace, uppercase |
|
||||
| `{typography.body-md}` | 16px | 400 | 1.5 | 0 | Default body — Bugatti Text Regular (a serif face), sentence case, no tracking |
|
||||
| `{typography.body-sm}` | 14px | 400 | 1.5 | 0 | Footer body, fine-print legal — Bugatti Text Regular |
|
||||
| `{typography.button}` | 14px | 400 | 1.0 | 2.5px | All button labels — Bugatti Monospace, uppercase, 2.5px tracking |
|
||||
| `{typography.nav-link}` | 12px | 400 | 1.4 | 2px | Top-nav menu items ("MENU", "STORE") — Bugatti Monospace |
|
||||
|
||||
### Principles
|
||||
The system NEVER uses bold weight. Every Bugatti typeface is set at weight 400 (regular). Visual emphasis comes from:
|
||||
1. **Size** — 64px hero vs 16px body is a 4× hierarchy
|
||||
2. **Letter-spacing** — 6px wordmark vs 0px body
|
||||
3. **Case** — Uppercase display vs sentence-case body
|
||||
4. **Family contrast** — Display vs Text Regular vs Monospace
|
||||
|
||||
Going to weight 700 anywhere would break the "modest engineering" feel and make Bugatti read like a generic luxury template.
|
||||
|
||||
The serif Bugatti Text Regular sets the brand apart from the all-sans luxury crowd (BMW, Aston Martin, Lamborghini all use sans-serif body type). Bugatti's serif body voice signals literary, considered, slow-reading prose — which is the brand's editorial philosophy.
|
||||
|
||||
### Note on Font Substitutes
|
||||
If Bugatti Display, Bugatti Text Regular, and Bugatti Monospace are unavailable, the closest open-source substitutes are:
|
||||
- **Bugatti Display** → **Saira Condensed** (variable, weight 400) at +0.05em letter-spacing
|
||||
- **Bugatti Text Regular** → **Cormorant Garamond** (regular) or **EB Garamond**
|
||||
- **Bugatti Monospace** → **JetBrains Mono** or **IBM Plex Mono** (regular weight)
|
||||
|
||||
The substitution preserves the three-family split, which is more important than exact typeface match.
|
||||
|
||||
## Layout
|
||||
|
||||
### Spacing System
|
||||
- **Base unit:** 4px.
|
||||
- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 24px · `{spacing.xl}` 40px · `{spacing.xxl}` 64px · `{spacing.section}` 120px.
|
||||
- **Section padding:** `{spacing.section}` (120px) — longer than most marketing sites because Bugatti's bands are mostly photography with minimal text. The empty space frames the cars.
|
||||
- **Card internal padding:** `{spacing.lg}` (24px) for newsroom and content cards; `{spacing.md}` (16px) for the career callout card; `{spacing.xxl}` (64px) inside hero photo bands.
|
||||
- **Gutters:** `{spacing.xl}` (40px) between cards in 2-up grids — wider than typical because Bugatti's grids are sparse.
|
||||
|
||||
### Grid & Container
|
||||
- **Max content width:** ~1280px centered. Hero photo bands bleed full-width with no max.
|
||||
- **Editorial body:** Single 12-column grid; photo bands are full-bleed.
|
||||
- **Newsroom layout:** 2-up article grid at desktop, 1-up at tablet+mobile.
|
||||
- **Career listings:** Single column with 80px row spacing.
|
||||
|
||||
### Whitespace Philosophy
|
||||
Bugatti uses whitespace more aggressively than any luxury-auto competitor. The homepage hero is mostly photography + huge whitespace + a single sentence + a single button. The empty black space below the photograph is intentional — it lets the car breathe. Compressing the whitespace to "fit more content" breaks the brand's fundamental contract: that less is more.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|---|---|---|
|
||||
| Flat | No shadow, no border | Body, top nav, footer, photo bands |
|
||||
| Soft hairline | 1px `{colors.hairline}` border | Section dividers, table rows |
|
||||
| Card surface | `{colors.surface-card}` background — no shadow | Career callout, newsroom article container |
|
||||
| Photographic depth | Full-bleed photography with edge-to-edge crop | Hero bands, model showcases — depth via subject + lens, not chrome |
|
||||
|
||||
The system uses no shadows, no glassmorphism, no gradients. Depth comes entirely from photography (lighting, lens, subject framing) and from the contrast between black canvas and minimally-elevated `{colors.surface-card}`.
|
||||
|
||||
### Decorative Depth
|
||||
- None. Bugatti is the only luxury-auto brand without a single decorative element. There is no stripe, no badge, no heritage emblem on the marketing site outside the wordmark itself.
|
||||
|
||||
## Shapes
|
||||
|
||||
### Border Radius Scale
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `{rounded.none}` | 0px | All cards, photo containers, inputs, spec cells — the dominant radius |
|
||||
| `{rounded.pill}` | 9999px | All buttons (the only rounded element in the system) |
|
||||
| `{rounded.full}` | 9999px / 50% | Circular icon buttons, avatar surfaces |
|
||||
|
||||
The radius hierarchy is binary: rectangular for everything except buttons, which are pills. No 4px, no 8px, no 12px in between — those would feel "designed" rather than "engineered."
|
||||
|
||||
### Photography Geometry
|
||||
Hero photography fills full-width with no rounding. Photo cards inside grids retain `{rounded.none}` (0px) corners, edge-to-edge images. Model detail shots use 16:9 or wider cinema-aspect ratios. Newsroom thumbnails use 16:9 with 0px corners. There are no avatars or rounded photo crops anywhere on the marketing site.
|
||||
|
||||
## Components
|
||||
|
||||
### Top Navigation
|
||||
|
||||
**`top-nav`** — A 56px-tall transparent nav bar overlaid on the hero photo at the top of every page. No fill, no border. Carries "MENU" at left, the centered **wordmark-display** ("BUGATTI" in 14px Bugatti Display with 6px tracking), and "STORE" at right with a small bag icon. All labels in `{typography.nav-link}` (Bugatti Monospace, 12px, 2px tracking, uppercase).
|
||||
|
||||
**`wordmark-display`** — The "BUGATTI" wordmark itself. Bugatti Display at 14px, weight 400, 6px letter-spacing. The widest tracking in the system. Centered in the nav bar at every breakpoint.
|
||||
|
||||
### Buttons
|
||||
|
||||
**`button-primary`** — The signature primary CTA. Background **transparent**, text `{colors.on-dark}` (white), 1px white outline, rounded `{rounded.pill}` (9999px), padding 14px × 32px, height 44px. Type `{typography.button}` — Bugatti Monospace, uppercase, 14px, 2.5px tracking. The transparent fill is unique to Bugatti — every other luxury-auto brand uses a filled or outlined-with-text-shift button. Bugatti's transparent pill IS the button.
|
||||
|
||||
**`button-icon`** — Circular icon buttons (carousel arrows, share, language switcher). 40 × 40px, transparent background, white outline 1px, rounded `{rounded.full}`. Same outline-only treatment as the primary button.
|
||||
|
||||
**`text-link`** — Inline body links in `{colors.link}` (#c3d9f3, the only non-monochrome color in the system). Underlined by default. Type inherits `{typography.body-md}` (Bugatti Text Regular, serif).
|
||||
|
||||
### Cards & Containers
|
||||
|
||||
**`hero-photo-band`** — Full-width black band with full-bleed automotive photography. The h1 in `{typography.display-xl}` sits center-aligned over the photo near the top, often paired with a small Bugatti Monospace caption (`{typography.caption-uppercase}`) below the headline and a single `{component.button-primary}` further down. Vertical padding 96px-200px depending on photo height.
|
||||
|
||||
**`career-callout-card`** — A small right-aligned card that floats over the hero photo on the homepage with a recruiting prompt ("Are you ready for a new adventure?"). Background `{colors.surface-card}`, rounded `{rounded.none}` (0px), padding `{spacing.md}` (16px), width 320px. Carries a small thumbnail at top, body line, and a `{typography.caption-uppercase}` link ("EXPLORE OUR OPPORTUNITIES").
|
||||
|
||||
**`model-photo-card`** — Used in model showcases (Tourbillon page, model lineup grid). Background `{colors.canvas}` (no card surface — just photo on black), rounded `{rounded.none}`. Top: 16:9 or 21:9 hero shot of the model. Below: model name in `{typography.display-md}` (32px Bugatti Display, 2px tracking), short specs line in `{typography.caption-uppercase}` (11px Bugatti Monospace), a `{component.text-link}` ("DISCOVER").
|
||||
|
||||
**`newsroom-article-card`** — Used on the newsroom page (newsroom.bugatti.com). Background `{colors.canvas}` with hairline border, rounded `{rounded.none}`, padding `{spacing.lg}` (24px). Carries a 16:9 thumbnail, a `{component.date-pill}` ("12. NOVEMBER 2025"), a `{typography.title-md}` headline, and a body excerpt in `{typography.body-md}` (Bugatti Text Regular serif).
|
||||
|
||||
**`career-listing-row`** — Each row of the careers page job listing. Transparent background, padding 24px vertical, hairline divider between rows. Job title in `{typography.title-md}` (Bugatti Display 20px) at left; location + department in `{typography.caption-uppercase}` at right; chevron arrow (→) at far right.
|
||||
|
||||
**`spec-cell`** — Vehicle technical-spec display on model-detail pages (Tourbillon engine specs). Transparent background with hairline dividers between cells (not between cells inside a card). Each spec shows a value in `{typography.title-md}` at top and a label in `{typography.caption-uppercase}` below. Padding 24px vertical.
|
||||
|
||||
### Inputs & Forms
|
||||
|
||||
**`text-input`** — Standard text input on dark canvas. Background **transparent**, text `{colors.on-dark}`, 1px hairline-strong bottom border only (no top, left, right border), padding 12px × 0px, height 44px. Type `{typography.body-md}` (Bugatti Text Regular). Placeholder in `{colors.muted}`. Focus thickens the bottom border to white.
|
||||
|
||||
### Tags & Captions
|
||||
|
||||
**`caption-overlay`** — Photo-overlay caption (e.g., "HONORING THE OEYRON AND ITS VISIONARY CREATOR"). Centered or left-aligned over photography in `{typography.caption-uppercase}` (Bugatti Monospace, 11px, 2px tracking, white).
|
||||
|
||||
**`category-tag`** + **`date-pill`** — Both render as transparent inline labels in `{typography.caption-uppercase}`, color `{colors.muted}`. No background fill, no border. The "tag" is the type itself.
|
||||
|
||||
### CTA / Footer
|
||||
|
||||
**`cta-band-photo`** — A pre-footer "Discover Bugatti" band with full-bleed photography of a Bugatti car at speed and a centered headline in `{typography.display-md}` + a `{component.button-primary}` below. Vertical padding 80px. Inherits the editorial gravity of the hero through full-bleed photography.
|
||||
|
||||
**`footer`** — Black footer that closes every page. Background `{colors.canvas}`, text `{colors.muted}`. 4-column link list at desktop covering Bugatti / Models / Heritage / Connect. Vertical padding 64px. Bottom row carries the copyright line in `{typography.body-sm}` (Bugatti Text Regular). The wordmark sits center-aligned at the very bottom. The footer never inverts.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Anchor every page with full-bleed automotive photography. The cars are the brand voltage; chrome backs off entirely.
|
||||
- Keep all display headlines in UPPERCASE Bugatti Display with 2-4px letter-spacing. The wordmark gets 6px.
|
||||
- Use Bugatti Display for headlines, Bugatti Text Regular (serif!) for body, Bugatti Monospace for buttons + captions + nav. The trinity is unbreakable.
|
||||
- Keep `{component.button-primary}` transparent with a 1px white outline. The transparent pill IS the brand button.
|
||||
- Use weight 400 everywhere. Bold breaks the brand voice — the system has no bold weight role.
|
||||
- Use `{spacing.section}` (120px) between major editorial bands. The whitespace is part of the brand.
|
||||
- Reserve `{colors.link}` (#c3d9f3) for inline anchor links only. It's the system's only non-monochrome color.
|
||||
|
||||
### Don't
|
||||
- Don't introduce any accent color outside `{colors.link}`. Bugatti's brand discipline is total monochrome + photography. Adding a brand-blue or brand-red breaks the contract.
|
||||
- Don't bold any type. The system has no bold weight — every typeface stays at 400.
|
||||
- Don't fill primary buttons. Transparent + outline only. A solid white button reads as off-brand.
|
||||
- Don't compress whitespace between sections. The 120px rhythm is part of the editorial pacing.
|
||||
- Don't use rounded corners outside buttons. Cards, photos, inputs all stay at 0px. Rounded cards read as consumer-tech, not luxury-engineered.
|
||||
- Don't tighten letter-spacing on display headlines. 2-4px tracking on Bugatti Display is non-negotiable.
|
||||
- Don't use Bugatti Display in a button (use Bugatti Monospace) or Bugatti Monospace in a paragraph (use Bugatti Text Regular). The trinity split is the brand voice.
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Name | Width | Key Changes |
|
||||
|---|---|---|
|
||||
| Mobile | < 768px | Hamburger nav; hero h1 64→32px; career callout card hides; photo bands stay full-bleed; footer 4 cols → 1 |
|
||||
| Tablet | 768–1024px | Top nav stays minimal (MENU + wordmark + STORE); 2-up newsroom grid; career rows full-width |
|
||||
| Desktop | 1024–1440px | Full minimal top-nav; 2-up newsroom grid; spec tables 4-up |
|
||||
| Wide | > 1440px | Same as desktop with more breathing room; max content 1280px |
|
||||
|
||||
### Touch Targets
|
||||
- `{component.button-primary}` renders at minimum 44 × 44px (matches WCAG AAA).
|
||||
- `{component.button-icon}` is exactly 40 × 40px.
|
||||
- `{component.text-input}` height is 44px.
|
||||
- Career listing rows have 24px vertical padding; effective tap area meets 44px+ with surrounding spacing.
|
||||
|
||||
### Collapsing Strategy
|
||||
- Top nav stays minimal at all breakpoints (MENU label + wordmark + STORE label). On mobile the labels hide behind a hamburger but the wordmark stays centered.
|
||||
- Hero photography stays full-bleed at every breakpoint. Photo crops adjust — wider crops at desktop, vertical crops on mobile.
|
||||
- The career callout card on the homepage hides at < 768px (it's a desktop-only floating element).
|
||||
- 2-up newsroom grid collapses to 1-up at < 768px.
|
||||
- Spec cells reflow from 4-up to 2-up to 1-up; values stay at the same display size regardless of column count.
|
||||
|
||||
### Image Behavior
|
||||
- Hero photography crops responsively — wider crops at desktop, vertical crops on mobile. Bugatti cars are always shown in motion or at-angle (never flat profiles).
|
||||
- Newsroom thumbnails retain 16:9 ratio and 0px corners.
|
||||
|
||||
## Iteration Guide
|
||||
|
||||
1. Focus on ONE component at a time. Reference its YAML key (`{component.hero-photo-band}`, `{component.career-callout-card}`).
|
||||
2. New components default to `{rounded.none}` (0px). Only `{component.button-primary}` and `{component.button-icon}` use pill / full radius.
|
||||
3. Variants live as separate entries in `components:`.
|
||||
4. Use `{token.refs}` everywhere — never inline hex.
|
||||
5. Never document hover. Default and Active/Pressed states only.
|
||||
6. Display headlines stay UPPERCASE Bugatti Display 400 with 2-4px tracking. Body stays sentence-case Bugatti Text Regular (serif). Button labels stay Bugatti Monospace 2.5px tracking. The trinity does not blur.
|
||||
7. When in doubt about emphasis: bigger photography before bigger type.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
- The dembrandt frequency analyzer captured only 3 colors at root level (`#000000`, `#999999`, `#c3d9f3`). The white text (#ffffff) and dark surface tones (`#0d0d0d`, `#141414`, `#1f1f1f`) were inferred from screenshot — Bugatti's pages are so monochrome that the frequency-based analyzer didn't surface body text or surface tones as distinct palette entries.
|
||||
- The three Bugatti typefaces (Display, Text Regular, Monospace) are licensed to Bugatti and not available as web fonts publicly. Substitutes are documented in the typography section.
|
||||
- Animation and transition timings (photo carousel transitions, hover-reveal of menu, configurator animations) are not in scope.
|
||||
- Form validation states beyond the underline-only `{component.text-input}` are not extracted — error / success states are inferred from general standards, not from the analyzed surfaces.
|
||||
- The configurator surface (vehicle build pages with custom paint / interior pickers) was not in the analyzed URL set; its swatch grid, customization controls, and price-summary card are not documented here.
|
||||
- The German-language newsroom (newsroom.bugatti.com/de) shares the system with the English Bugatti.com surfaces — no design-system-level differences observed, only language localization.
|
||||
- The actual Tourbillon page rendered as a sparse minimal page in the captured screenshot, suggesting either lazy-loaded content or an interactive configurator-style UI that doesn't render fully in static screenshots; engine-spec layout is documented from general luxury-auto patterns informed by the captured spec cell tokens.
|
||||
@@ -6,14 +6,15 @@ Re-runnable scheduled. Python 3.12, venv `./venv`.
|
||||
|
||||
## §C — Constraints
|
||||
- venv `./venv` exists (py3.12, only pip) — install deps there, ⊥ global
|
||||
- lib: `requests` ! pip-installed; others stdlib
|
||||
- db: SQLite stdlib `sqlite3` → file `voters.db`
|
||||
- api: `https://v2.vexascan.com:2096` (public, flaky → retry)
|
||||
- contract=scope=`vexcore` (≠ `vexio`, EOS convention ⊥)
|
||||
- filter: ∃ voter where `producers.length==1 & producers[0]=='databisnisid'`
|
||||
- lib: `requests`, `flask`, `python-dotenv`, `gunicorn` ! pip-installed; others stdlib
|
||||
- config via env/`.env` (`config.py`, python-dotenv); default = konstanta lama
|
||||
- db: SQLite stdlib `sqlite3` → `DB_PATH` (default `voters.db`); WAL journal mode → pembaca tak terblokir saat snapshot harian
|
||||
- api: `API_NODE` (default `https://v2.vexascan.com:2096`, public, flaky → retry)
|
||||
- contract=scope=`vexcore` (≠ `vexio`, EOS convention ⊥) — ⊥ env-able
|
||||
- filter: ∃ voter where `producers.length==1 & producers[0]==TARGET_BP`
|
||||
- `staked` scaled ×10000 → store ÷10000
|
||||
- weight = `last_vote_weight` (string, high-precision decimal) — keep verbatim
|
||||
- staked ≥ 1000 VEX ! required (÷10000 dulu)
|
||||
- staked ≥ `MIN_STAKED_VEX` (default 1000 VEX) ! required (÷10000 dulu)
|
||||
- output/comments: Indonesian
|
||||
- run harian → ganti snapshot (DROP+INSERT tiap skan; ⊥ history/append)
|
||||
|
||||
@@ -23,6 +24,10 @@ api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows`
|
||||
→ rows[owner, proxy, producers[], staked, last_vote_weight, proxied_vote_weight, is_proxy], more, next_key
|
||||
db: `voters.db` → table `voters` (owner PK, weight, staked, scanned_at) — ganti tiap skan
|
||||
cmd: `./venv/bin/python get_voters.py` → stdout summary (id-ID)
|
||||
web: GET `/` (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 50/halaman, `ORDER BY staked DESC, owner ASC`; ⊥ mutation (read-only)
|
||||
web: GET `/` + `?q=<substring>` → filter owner (server-side, no-JS fallback); pager bawa `q`
|
||||
web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank}]}`, rank global, cap 500
|
||||
env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS` — via `config.py` (`.env`)
|
||||
|
||||
## §V — Invariants
|
||||
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry)
|
||||
@@ -34,6 +39,9 @@ V6: weight stored string verbatim, ⊥ float conversion
|
||||
V7: each scan replaces table; ∀ row → latest snapshot; ⊥ append/duplicate owner
|
||||
V8: normalize coerces `staked` → float (node kadang memberi string)
|
||||
V9: normalize drops voters where `staked` < 1000 VEX (setelah ÷10000)
|
||||
V10: dashboard baca `voters.db` → rows sort `staked` DESC (terbesar dulu); ⊥ mutasi DB
|
||||
V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensitive); urut+paging terjaga; `rank` global (⊥ rank-dalam-filter); `?q=` & `/api/search` read-only
|
||||
V12: produksi web disajikan gunicorn (`dashboard:app`, `gunicorn.conf.py`); `DASH_WORKERS` default 2; `.env` ikut termuat via import `config`
|
||||
|
||||
## §T — Tasks
|
||||
id|status|task|cites
|
||||
@@ -42,6 +50,14 @@ T2|x|scan loop: retry + pagination|V1,V2,V3,I.api
|
||||
T3|x|filter + normalize (owner, staked÷10000, weight ≥1000)|V4,V5,V6,V8,V9
|
||||
T4|x|sqlite schema + replace-snapshot w/ scanned_at|V7,I.db
|
||||
T5|x|CLI entry + Indonesian summary stdout|I.cmd
|
||||
T6|x|dashboard: Flask read-only, paged spec-list sort staked DESC|V10,I.web,I.db
|
||||
T7|x|dashboard: styling per DESIGN.md (canvas hitam, typografi, hairline)|I.web
|
||||
T8|x|dashboard: test oracle + smoke|V10,I.web
|
||||
T9|x|env config: config.py + .env/.env.example + dotenv; get_voters & dashboard pakai config|I.env
|
||||
T10|x|search: ?q= server filter + /api/search JSON (substring, escape, rank global)|V11,I.web
|
||||
T11|x|live JS filter (app.js) + responsive (DESIGN.md breakpoints, list→kartu mobile)|V11,I.web
|
||||
T12|x|dashboard oracle + search/API/config tests|V11,V10
|
||||
T13|x|produksi web: gunicorn (gunicorn.conf.py, DASH_WORKERS) + WAL di persist|V12,I.web,I.env
|
||||
|
||||
## §B — Bug log
|
||||
id|date|cause|fix
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Konfigurasi proyek dari env / `.env`.
|
||||
|
||||
Bacaan tipe via `os.getenv`; nilai default = konstanta lama. Muat `.env` sekali
|
||||
di sini supaya semua modul (get_voters, dashboard) berbagi sumber yang sama.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# BP target
|
||||
TARGET_BP = os.getenv('VEX_TARGET_BP', 'databisnisid')
|
||||
|
||||
# Node RPC publik Vexanium
|
||||
API_NODE = os.getenv('VEX_API_NODE', 'https://v2.vexascan.com:2096')
|
||||
|
||||
# Lokasi basis data SQLite (dipakai fetcher & dashboard)
|
||||
DB_PATH = os.getenv('VEX_DB_PATH', 'voters.db')
|
||||
|
||||
# Syarat minimum stake (dalam VEX) untuk disimpan
|
||||
MIN_STAKED_VEX = float(os.getenv('VEX_MIN_STAKED_VEX', '1000'))
|
||||
|
||||
# Dashboard: ukuran halaman list
|
||||
PAGE_SIZE = int(os.getenv('DASH_PAGE_SIZE', '50'))
|
||||
|
||||
# Dashboard: host & port server Flask
|
||||
DASH_HOST = os.getenv('DASH_HOST', '127.0.0.1')
|
||||
DASH_PORT = int(os.getenv('DASH_PORT', '5000'))
|
||||
|
||||
# Dashboard: jumlah worker gunicorn (produksi)
|
||||
DASH_WORKERS = int(os.getenv('DASH_WORKERS', '2'))
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""Dashboard web pemilih BP databisnisid — baca `voters.db`, sajikan spec-list.
|
||||
|
||||
Read-only (V10/V11): tidak pernah menulis ke DB. Data disegarkan oleh `get_voters.py`
|
||||
(run harian / cron). Styling mengikuti `DESIGN.md`. Konfigurasi dari env/`.env`.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
from flask import Flask, abort, jsonify, render_template, request
|
||||
|
||||
from config import DASH_HOST, DASH_PORT, DB_PATH, PAGE_SIZE, TARGET_BP
|
||||
|
||||
SEARCH_CAP = 500
|
||||
MAX_Q_LEN = 64
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.template_filter('vex')
|
||||
def vex(value):
|
||||
"""Format jumlah VEX ala id-ID (koma ribuan, titik desimal)."""
|
||||
return f'{value:,.4f}'.replace(',', '\u00a0').replace('.', ',').replace('\u00a0', '.')
|
||||
|
||||
|
||||
def _escape_like(q):
|
||||
"""Escape wildcard LIKE agar `%`/`_` dari pengguna tidak jadi pola."""
|
||||
return (q.replace('\\', '\\\\')
|
||||
.replace('%', '\\%')
|
||||
.replace('_', '\\_'))
|
||||
|
||||
|
||||
def _filter_sql(q):
|
||||
"""V11: klausa WHERE + params untuk filter owner substring (case-insensitive)."""
|
||||
if not q:
|
||||
return '', []
|
||||
return "WHERE owner LIKE ? ESCAPE '\\'", [f'%{_escape_like(q)}%']
|
||||
|
||||
|
||||
def _db_rows(page, q):
|
||||
"""V10/V11: satu halaman, urut staked DESC lalu owner ASC; rank global.
|
||||
|
||||
Rank via correlated-count (bukan ROW_NUMBER): SQLite mendorong WHERE masuk ke
|
||||
subquery sebelum window function, sehingga ROW_NUMBER jadi rank-dalam-filter.
|
||||
Correlated count selalu menghitung atas SELURUH tabel → rank tetap global.
|
||||
"""
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
where, params = _filter_sql(q)
|
||||
sql = (
|
||||
'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
|
||||
' (SELECT COUNT(*) FROM voters v2 '
|
||||
' WHERE v2.staked > v.staked '
|
||||
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn '
|
||||
'FROM voters v ' + where + ' '
|
||||
'ORDER BY v.staked DESC, v.owner ASC LIMIT ? OFFSET ?'
|
||||
)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
return conn.execute(sql, params + [PAGE_SIZE, offset]).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _count(q):
|
||||
"""V11: banyak baris cocok filter (atau total bila tanpa filter)."""
|
||||
where, params = _filter_sql(q)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
return conn.execute(
|
||||
'SELECT COUNT(*) FROM voters ' + where, params
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _stats():
|
||||
"""Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search)."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
total_voters, total_staked, top_staked = conn.execute(
|
||||
'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) '
|
||||
'FROM voters'
|
||||
).fetchone()
|
||||
scanned = conn.execute(
|
||||
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters'
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
return total_voters, total_staked, top_staked, scanned
|
||||
|
||||
|
||||
@app.get('/')
|
||||
def index():
|
||||
try:
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
except ValueError:
|
||||
abort(400)
|
||||
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
|
||||
total_voters, total_staked, top_staked, scanned = _stats()
|
||||
filtered_count = _count(q)
|
||||
total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = min(page, total_pages)
|
||||
rows = _db_rows(page, q)
|
||||
|
||||
return render_template(
|
||||
'index.html',
|
||||
bp=TARGET_BP,
|
||||
rows=rows,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total_voters=total_voters,
|
||||
total_staked=total_staked,
|
||||
top_staked=top_staked,
|
||||
scanned=scanned,
|
||||
q=q,
|
||||
filtered_count=filtered_count,
|
||||
)
|
||||
|
||||
|
||||
@app.get('/api/search')
|
||||
def api_search():
|
||||
"""V11: JSON hasil pencarian owner (substring), urut staked DESC, rank global."""
|
||||
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
|
||||
where, params = _filter_sql(q)
|
||||
sql = (
|
||||
'SELECT v.owner, v.staked, v.weight, '
|
||||
' (SELECT COUNT(*) FROM voters v2 '
|
||||
' WHERE v2.staked > v.staked '
|
||||
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn '
|
||||
'FROM voters v ' + where + ' '
|
||||
'ORDER BY v.staked DESC, v.owner ASC LIMIT ?'
|
||||
)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
rows = conn.execute(sql, params + [SEARCH_CAP]).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'query': q,
|
||||
'count': len(rows),
|
||||
'cap': SEARCH_CAP,
|
||||
'results': [
|
||||
{'owner': r[0], 'staked': r[1], 'weight': r[2], 'rank': r[3]}
|
||||
for r in rows
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host=DASH_HOST, port=DASH_PORT, debug=False)
|
||||
+7
-12
@@ -10,11 +10,7 @@ from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
# Akun Block Producer (BP) yang dicari
|
||||
TARGET_BP = 'databisnisid'
|
||||
|
||||
# Node RPC publik Vexanium
|
||||
API_NODE = 'https://v2.vexascan.com:2096'
|
||||
from config import API_NODE, DB_PATH, MIN_STAKED_VEX, TARGET_BP
|
||||
|
||||
# Kontrak & scope sistem Vexanium = vexcore (BUKAN vexio sesuai konvensi EOS)
|
||||
CODE = 'vexcore'
|
||||
@@ -24,12 +20,6 @@ TABLE = 'voters'
|
||||
# Ukuran halaman saat paginasi
|
||||
LIMIT = 500
|
||||
|
||||
# Syarat minimum stake (dalam VEX) untuk disimpan
|
||||
MIN_STAKED_VEX = 1000
|
||||
|
||||
# Lokasi basis data SQLite
|
||||
DB_PATH = 'voters.db'
|
||||
|
||||
# Ambang-batas nama akun "0" (kodifikasi uint64 0) pada tabel voters
|
||||
ZERO_OWNER = '...........q'
|
||||
|
||||
@@ -107,10 +97,15 @@ def normalize(row):
|
||||
|
||||
|
||||
def persist(rows):
|
||||
"""V7: ganti snapshot — DROP+INSERT tiap skan, ⊥ history/append."""
|
||||
"""V7: ganti snapshot — DROP+INSERT tiap skan, ⊥ history/append.
|
||||
|
||||
WAL: journal_mode=WAL → pembaca (dashboard multi-worker) tak terblokir
|
||||
saat DROP+INSERT harian. Mode ini persisten setelah diset.
|
||||
"""
|
||||
scanned_at = datetime.now().isoformat(timespec='seconds')
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
conn.execute('DROP TABLE IF EXISTS voters') # snapshot bersih
|
||||
conn.execute(
|
||||
'CREATE TABLE voters (' # I.db
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Konfigurasi gunicorn untuk dashboard (produksi).
|
||||
|
||||
Dipanggil lewat `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app`.
|
||||
Import `config` di sini → `.env` ikut termuat (gunicorn sendiri ⊥ baca dotenv).
|
||||
Setiap request membuka koneksi SQLite sendiri → worker sync aman & paling sederhana.
|
||||
"""
|
||||
|
||||
from config import DASH_HOST, DASH_PORT, DASH_WORKERS
|
||||
|
||||
# Bind dari config (DASH_HOST / DASH_PORT), bukan hardcoded
|
||||
bind = f'{DASH_HOST}:{DASH_PORT}'
|
||||
|
||||
# Worker prefork; default 2 (V12), bisa dioverride via env DASH_WORKERS
|
||||
workers = DASH_WORKERS
|
||||
|
||||
worker_class = 'sync'
|
||||
|
||||
# Log ke stdout/stderr agar tampil di journal/systemd
|
||||
accesslog = '-'
|
||||
errorlog = '-'
|
||||
@@ -1 +1,4 @@
|
||||
requests>=2.31
|
||||
flask>=3.0
|
||||
python-dotenv>=1.0
|
||||
gunicorn>=22.0
|
||||
@@ -0,0 +1,89 @@
|
||||
// Live filter daftar pemilih by owner (V11). Tanpa JS: form GET ?q= tetap jalan.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var input = document.getElementById('search-input');
|
||||
var rows = document.getElementById('rows');
|
||||
var pager = document.getElementById('pager');
|
||||
var result = document.getElementById('search-result');
|
||||
|
||||
if (!input || !rows || !pager) {
|
||||
return;
|
||||
}
|
||||
|
||||
var timer = null;
|
||||
var originalRows = null;
|
||||
var originalPager = null;
|
||||
var DEBOUNCE_MS = 200;
|
||||
|
||||
function escapeHtml(text) {
|
||||
var el = document.createElement('div');
|
||||
el.textContent = text == null ? '' : String(text);
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function vex(value) {
|
||||
return Number(value).toLocaleString('id-ID', {
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4
|
||||
});
|
||||
}
|
||||
|
||||
function setSearchMode(on) {
|
||||
if (on && originalRows === null) {
|
||||
originalRows = rows.innerHTML;
|
||||
originalPager = pager.style.display || '';
|
||||
} else if (!on && originalRows !== null) {
|
||||
rows.innerHTML = originalRows;
|
||||
pager.style.display = originalPager;
|
||||
originalRows = null;
|
||||
originalPager = null;
|
||||
if (result) {
|
||||
result.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults(data) {
|
||||
if (!data.results.length) {
|
||||
rows.innerHTML = '<div class="empty">TAK ADA AKUN COCOK</div>';
|
||||
} else {
|
||||
rows.innerHTML = data.results.map(function (r) {
|
||||
return '<div class="list-row">' +
|
||||
'<span class="rank">' + r.rank + '</span>' +
|
||||
'<span class="owner">' + escapeHtml(r.owner) + '</span>' +
|
||||
'<span class="stake" data-label="STAKE (VEX)">' + vex(r.staked) + '</span>' +
|
||||
'<span class="weight" data-label="BOBOT SUARA">' + escapeHtml(r.weight) + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
if (result) {
|
||||
result.textContent = 'HASIL · ' + data.count + ' AKUN UNTUK "' + data.query + '"';
|
||||
}
|
||||
pager.style.display = 'none';
|
||||
}
|
||||
|
||||
function liveSearch(q) {
|
||||
fetch('/api/search?q=' + encodeURIComponent(q))
|
||||
.then(function (res) {
|
||||
return res.json();
|
||||
})
|
||||
.then(renderResults)
|
||||
.catch(function () {
|
||||
// Biarkan baris server tetap tampil bila fetch gagal.
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
clearTimeout(timer);
|
||||
var q = input.value.trim();
|
||||
if (q === '') {
|
||||
setSearchMode(false);
|
||||
return;
|
||||
}
|
||||
setSearchMode(true);
|
||||
timer = setTimeout(function () {
|
||||
liveSearch(q);
|
||||
}, DEBOUNCE_MS);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,315 @@
|
||||
/* DESIGN.md — Bugatti austere system, mapped 1:1 */
|
||||
:root {
|
||||
/* colors */
|
||||
--ink: #ffffff;
|
||||
--body: #cccccc;
|
||||
--body-strong: #e6e6e6;
|
||||
--muted: #999999;
|
||||
--muted-soft: #666666;
|
||||
--hairline: #262626;
|
||||
--hairline-strong: #3a3a3a;
|
||||
--canvas: #000000;
|
||||
--surface-soft: #0d0d0d;
|
||||
--surface-card: #141414;
|
||||
--on-dark: #ffffff;
|
||||
--link: #c3d9f3;
|
||||
|
||||
/* typography — DESIGN.md substitutes: Saira Condensed (Display),
|
||||
EB Garamond (Text Regular), JetBrains Mono (Monospace) */
|
||||
--font-display: 'Saira Condensed', 'Arial Narrow', -apple-system, sans-serif;
|
||||
--font-text: 'EB Garamond', Garamond, 'Times New Roman', serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
|
||||
|
||||
/* spacing */
|
||||
--sp-xxs: 4px; --sp-xs: 8px; --sp-sm: 12px; --sp-md: 16px;
|
||||
--sp-lg: 24px; --sp-xl: 40px; --sp-xxl: 64px; --sp-section: 120px;
|
||||
|
||||
/* layout */
|
||||
--max-content: 1280px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { background: var(--canvas); }
|
||||
|
||||
body {
|
||||
background: var(--canvas);
|
||||
color: var(--on-dark);
|
||||
font-family: var(--font-text);
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ————— top nav ————— */
|
||||
.top-nav {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-lg);
|
||||
max-width: var(--max-content);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.nav-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-dark);
|
||||
}
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
letter-spacing: 6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
/* ————— page / hero ————— */
|
||||
.page { width: 100%; max-width: var(--max-content); margin: 0 auto; flex: 1; }
|
||||
|
||||
.hero { padding: var(--sp-xxl) var(--sp-lg) var(--sp-lg); }
|
||||
.display-xl {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(40px, 6vw, 64px);
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-dark);
|
||||
}
|
||||
.caption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-top: var(--sp-md);
|
||||
}
|
||||
.caption .ts {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ————— stats band ————— */
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
border-top: 1px solid var(--hairline);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
margin: 0 var(--sp-lg) var(--sp-xl);
|
||||
}
|
||||
.spec-cell {
|
||||
padding: var(--sp-lg) var(--sp-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
.spec-cell + .spec-cell { border-left: 1px solid var(--hairline); }
|
||||
.cell-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-dark);
|
||||
}
|
||||
.cell-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ————— search ————— */
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-md);
|
||||
margin: 0 var(--sp-lg) var(--sp-sm);
|
||||
max-width: 560px;
|
||||
}
|
||||
.search-input {
|
||||
background: transparent;
|
||||
color: var(--on-dark);
|
||||
font-family: var(--font-text);
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--hairline-strong);
|
||||
border-radius: 0;
|
||||
padding: var(--sp-sm) 0;
|
||||
height: 44px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-input::placeholder { color: var(--muted); }
|
||||
.search-input:focus { outline: none; border-bottom-color: var(--on-dark); }
|
||||
button.pill {
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.clear {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
padding-bottom: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-result { margin: 0 var(--sp-lg); }
|
||||
.search-result:empty { display: none; }
|
||||
|
||||
/* ————— spec-list ————— */
|
||||
.spec-list { margin: var(--sp-md) var(--sp-lg) 0; border-top: 1px solid var(--hairline-strong); }
|
||||
.list-header,
|
||||
.list-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr 200px 1fr;
|
||||
gap: var(--sp-lg);
|
||||
align-items: baseline;
|
||||
padding: var(--sp-lg) var(--sp-sm);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.list-header {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding-top: var(--sp-md);
|
||||
}
|
||||
.list-row:nth-child(odd) { background: var(--surface-soft); }
|
||||
.rank,
|
||||
.weight {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.owner {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--on-dark);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.stake {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
text-align: right;
|
||||
color: var(--on-dark);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.empty {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: var(--sp-xxl) var(--sp-sm);
|
||||
}
|
||||
|
||||
/* ————— pager ————— */
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-lg);
|
||||
padding: var(--sp-xxl) var(--sp-lg);
|
||||
}
|
||||
.pill {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
letter-spacing: 2.5px;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: var(--on-dark);
|
||||
border: 1px solid var(--on-dark);
|
||||
border-radius: 9999px;
|
||||
padding: 14px 32px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.pill-disabled {
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
border-color: var(--muted-soft);
|
||||
}
|
||||
.page-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ————— footer ————— */
|
||||
.footer {
|
||||
padding: var(--sp-xxl) var(--sp-lg);
|
||||
margin-top: var(--sp-section);
|
||||
border-top: 1px solid var(--hairline);
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-soft);
|
||||
}
|
||||
|
||||
/* ————— tablet (768–1024px) ————— */
|
||||
@media (max-width: 1024px) {
|
||||
.list-header,
|
||||
.list-row { grid-template-columns: 56px 1fr 160px 1fr; gap: var(--sp-md); }
|
||||
.spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); }
|
||||
}
|
||||
|
||||
/* ————— mobile (<768px): list → kartu berlabel ————— */
|
||||
@media (max-width: 768px) {
|
||||
.stats { grid-template-columns: 1fr; }
|
||||
.spec-cell + .spec-cell { border-left: none; border-top: 1px solid var(--hairline); }
|
||||
|
||||
.search { flex-direction: column; align-items: stretch; }
|
||||
.search .pill { justify-content: center; width: 100%; }
|
||||
|
||||
.list-header { display: none; }
|
||||
.list-row {
|
||||
grid-template-columns: 48px 1fr;
|
||||
gap: var(--sp-xxs) var(--sp-sm);
|
||||
padding: var(--sp-lg) var(--sp-sm);
|
||||
}
|
||||
.list-row .rank { grid-row: 1 / span 3; grid-column: 1; align-self: start; padding-top: 6px; }
|
||||
.list-row .owner { grid-column: 2; font-size: 18px; }
|
||||
.list-row .stake,
|
||||
.list-row .weight { grid-column: 2; }
|
||||
.list-row .stake { text-align: left; }
|
||||
.list-row .stake,
|
||||
.list-row .weight { overflow-wrap: anywhere; }
|
||||
.list-row .stake::before,
|
||||
.list-row .weight::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.pager { flex-direction: column; gap: var(--sp-md); }
|
||||
.pager .pill { justify-content: center; width: 100%; }
|
||||
.page-count { order: -1; }
|
||||
}
|
||||
|
||||
/* ————— layar sangat kecil (<480px): sembunyikan nav-label ————— */
|
||||
@media (max-width: 480px) {
|
||||
.nav-label { display: none; }
|
||||
.hero { padding-top: var(--sp-lg); }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DAFTAR PEMILIH — {{ bp }}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-nav">
|
||||
<span class="nav-label">DAFTAR</span>
|
||||
<span class="wordmark">DATABISNISID</span>
|
||||
<span class="nav-label">VEX</span>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
<section class="hero">
|
||||
<p class="caption">PEMILIH BP · SUARA TUNGGAL · STAKE ≥ 1.000 VEX</p>
|
||||
<h1 class="display-xl">DAFTAR PEMILIH</h1>
|
||||
<p class="caption">PEMINDAIAN TERAKHIR · <span class="ts">{{ scanned }}</span></p>
|
||||
</section>
|
||||
|
||||
<section class="stats">
|
||||
<div class="spec-cell">
|
||||
<span class="cell-value">{{ total_voters }}</span>
|
||||
<span class="cell-label">TOTAL PEMILIH</span>
|
||||
</div>
|
||||
<div class="spec-cell">
|
||||
<span class="cell-value">{{ total_staked|vex }}</span>
|
||||
<span class="cell-label">TOTAL VEX</span>
|
||||
</div>
|
||||
<div class="spec-cell">
|
||||
<span class="cell-value">{{ top_staked|vex }}</span>
|
||||
<span class="cell-label">STAKE TERTINGGI</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="search" method="get" action="{{ url_for('index') }}">
|
||||
<input class="search-input" type="search" name="q" id="search-input"
|
||||
value="{{ q }}" placeholder="CARI AKUN..." autocomplete="off">
|
||||
<button class="pill" type="submit">CARI</button>
|
||||
{% if q %}
|
||||
<a class="clear" href="{{ url_for('index') }}">HAPUS</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
<p class="caption search-result" id="search-result">
|
||||
{% if q %}HASIL · {{ filtered_count }} AKUN UNTUK "{{ q }}"{% endif %}
|
||||
</p>
|
||||
|
||||
<section class="spec-list" id="spec-list">
|
||||
<div class="list-header">
|
||||
<span>RANK</span>
|
||||
<span>AKUN</span>
|
||||
<span>STAKE (VEX)</span>
|
||||
<span>BOBOT SUARA</span>
|
||||
</div>
|
||||
<div class="list-rows" id="rows">
|
||||
{% for row in rows %}
|
||||
<div class="list-row">
|
||||
<span class="rank">{{ row[4] }}</span>
|
||||
<span class="owner">{{ row[0] }}</span>
|
||||
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
|
||||
<span class="weight" data-label="BOBOT SUARA">{{ row[2] }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
{% if q %}TAK ADA AKUN COCOK — "{{ q }}"{% else %}BELUM ADA DATA — JALANKAN get_voters.py{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="pager" id="pager" aria-label="Navigasi halaman">
|
||||
{% set qp = q if q else None %}
|
||||
<a class="pill {% if page <= 1 %}pill-disabled{% endif %}"
|
||||
href="{{ url_for('index', page=page-1, q=qp) }}">← SEBELUMNYA</a>
|
||||
<span class="page-count">{{ page }} / {{ total_pages }}</span>
|
||||
<a class="pill {% if page >= total_pages %}pill-disabled{% endif %}"
|
||||
href="{{ url_for('index', page=page+1, q=qp) }}">BERIKUTNYA →</a>
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<span>DATA TERVALIDASI DARI JARINGAN VEXANIUM · VEXCORE · VOTERS</span>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Uji verifikasi dashboard (oracle).
|
||||
|
||||
`./venv/bin/python test_dashboard.py` harus exit 0.
|
||||
Memakai DB sementara, bukan voters.db asli (read-only invariant tetap diverifikasi).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import dashboard
|
||||
|
||||
|
||||
def check(tag, cond):
|
||||
if not cond:
|
||||
raise AssertionError(f'GAGAL: {tag}')
|
||||
print(f'ok: {tag}')
|
||||
|
||||
|
||||
def make_db(path, rows):
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute('CREATE TABLE voters ('
|
||||
'owner TEXT PRIMARY KEY, weight TEXT NOT NULL, '
|
||||
'staked REAL NOT NULL, scanned_at TEXT NOT NULL)')
|
||||
conn.executemany(
|
||||
'INSERT INTO voters (owner, weight, staked, scanned_at) VALUES (?, ?, ?, ?)',
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
tmp = tempfile.mkdtemp()
|
||||
db = os.path.join(tmp, 'voters.db')
|
||||
rows = []
|
||||
for i in range(1, 126): # 125 baris -> 3 halaman
|
||||
rows.append((f'acct{i:03d}', f'{i}.00000000000000000', float(i) * 10.0, '2026-08-04T00:00:00'))
|
||||
rows[5] = (rows[5][0], rows[5][1], 5.0 * 10.0, rows[5][3]) # non-monotonic guard
|
||||
make_db(db, rows)
|
||||
|
||||
dashboard.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan)
|
||||
client = dashboard.app.test_client()
|
||||
|
||||
# V10: urutan staked DESC (terbesar dulu), 50 per halaman
|
||||
resp = client.get('/?page=1')
|
||||
check('V10 halaman 1 → 200', resp.status_code == 200)
|
||||
html = resp.get_data(as_text=True)
|
||||
check('V10 judul halaman', 'DAFTAR PEMILIH' in html)
|
||||
|
||||
staked_vals = []
|
||||
for i in range(1, 4):
|
||||
resp = client.get(f'/?page={i}')
|
||||
check(f'V10 halaman {i} → 200', resp.status_code == 200)
|
||||
staked_vals += _extract_stakes(resp.get_data(as_text=True))
|
||||
check('V10 total 125 baris ditarik', len(staked_vals) == 125)
|
||||
check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1]
|
||||
for i in range(len(staked_vals) - 1)))
|
||||
|
||||
# halaman melebihi total → dibatasi (tidak error)
|
||||
resp = client.get('/?page=99')
|
||||
check('V10 halaman ekstra → 200', resp.status_code == 200)
|
||||
|
||||
# V10: read-only — tidak ada tabel baru / perubahan pada DB asli
|
||||
before = _table_hashes(db)
|
||||
client.get('/')
|
||||
check('V10 read-only (DB tak berubah)', _table_hashes(db) == before)
|
||||
|
||||
# ————— V11: search server-side ?q= (substring, case-insensitive) —————
|
||||
resp = client.get('/?q=acct12')
|
||||
check('V11 search ?q= → 200', resp.status_code == 200)
|
||||
html = resp.get_data(as_text=True)
|
||||
check('V11 hanya akun cocok', 'acct125' in html and 'acct120' in html
|
||||
and 'acct119' not in html)
|
||||
check('V11 caption HASIL · 6', 'HASIL · 6 AKUN' in html)
|
||||
q_vals = _extract_stakes(html)
|
||||
check('V11 hasil search urut DESC', q_vals == sorted(q_vals, reverse=True)
|
||||
and len(q_vals) == 6)
|
||||
|
||||
# pager membawa q
|
||||
check('V11 pager mempertahankan q', 'q=acct12' in html)
|
||||
|
||||
# halaman ekstra saat filter → dipatok ke total_pages filter
|
||||
resp = client.get('/?q=acct12&page=99')
|
||||
check('V11 clamp halaman saat search', resp.status_code == 200)
|
||||
|
||||
# search tanpa hasil → status kosong
|
||||
resp = client.get('/?q=zzzz')
|
||||
html = resp.get_data(as_text=True)
|
||||
check('V11 tak ada hasil → pesan kosong', 'TAK ADA AKUN COCOK' in html)
|
||||
|
||||
# ————— V11: /api/search (JSON, rank global via ROW_NUMBER) —————
|
||||
resp = client.get('/api/search?q=acct12')
|
||||
data = resp.get_json()
|
||||
check('V11 /api/search shape', set(data.keys()) == {'query', 'count', 'cap', 'results'})
|
||||
check('V11 /api/search count', data['count'] == 6)
|
||||
owners = [r['owner'] for r in data['results']]
|
||||
ranks = [r['rank'] for r in data['results']]
|
||||
check('V11 /api/search cocok substring',
|
||||
all('acct12' in o for o in owners))
|
||||
check('V11 /api/search rank global',
|
||||
ranks == sorted(ranks) and ranks == list(range(1, 7)))
|
||||
check('V11 /api/search rank sejajar staked tertinggi',
|
||||
[r['owner'] for r in data['results']] ==
|
||||
['acct125', 'acct124', 'acct123', 'acct122', 'acct121', 'acct120'])
|
||||
check('V11 /api/search urut staked DESC',
|
||||
[r['staked'] for r in data['results']] == sorted(
|
||||
[r['staked'] for r in data['results']], reverse=True))
|
||||
check('V11 /api/search bobot string verbatim',
|
||||
isinstance(data['results'][0]['weight'], str))
|
||||
|
||||
resp = client.get('/api/search?q=zzzz')
|
||||
check('V11 /api/search kosong', resp.get_json()['count'] == 0)
|
||||
|
||||
# rank HARUS global, bukan dalam-filter: 'acct00' menyentuh baris stake kecil
|
||||
resp = client.get('/api/search?q=acct00')
|
||||
data = resp.get_json()
|
||||
check('V11 rank global (bukan dalam-filter)',
|
||||
[r['rank'] for r in data['results']] == list(range(117, 126)))
|
||||
|
||||
# ————— env config: default = konstanta lama —————
|
||||
import config
|
||||
check('V11 config default TARGET_BP', config.TARGET_BP == 'databisnisid')
|
||||
check('V11 config default API_NODE', config.API_NODE == 'https://v2.vexascan.com:2096')
|
||||
check('V11 config default PAGE_SIZE', config.PAGE_SIZE == 50)
|
||||
check('V11 config default MIN_STAKED_VEX', config.MIN_STAKED_VEX == 1000.0)
|
||||
check('V11 config default DB_PATH', config.DB_PATH == 'voters.db')
|
||||
check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2)
|
||||
|
||||
|
||||
def _extract_stakes(text):
|
||||
"""Ambil nilai stake (dalam urutan kemunculan) dari baris HTML."""
|
||||
vals = []
|
||||
for token in text.split('class="list-row"')[1:]:
|
||||
stake_token = token.split('<span class="stake"')[1].split('</span>')[0].split('>', 1)[1]
|
||||
vals.append(_parse_vex(stake_token))
|
||||
return vals
|
||||
|
||||
|
||||
def _parse_vex(text):
|
||||
# format '1.749.791,2303' → float
|
||||
return float(text.replace('.', '').replace(',', '.'))
|
||||
|
||||
|
||||
def _table_hashes(path):
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
rows = conn.execute('SELECT owner, weight, staked, scanned_at '
|
||||
'FROM voters ORDER BY owner').fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user