Session 252 was a planning session that mapped the gap between what the admin console showed and what the FLIN runtime actually contained. The backend was 100% complete: FlinDB with 203 features, file management with 75 features, a storage engine with WAL, compaction, and snapshots. But the frontend was a collection of UI mockups. The storage page showed a pretty file browser with no files. The entities page showed a beautiful table with no data. The schema page displayed an elegant ER diagram with no entities.
The gap was not technical. The Rust backend had every API needed. The gap was integration -- connecting the HTML frontend to the JSON endpoints that the backend already served. This article covers three console sections that bridge that gap: the Storage Browser, the Database Backup Manager, and the Schema Visualizer.
The Storage Browser
FLIN's file management system supports four storage backends: local disk, Amazon S3, Cloudflare R2, and Google Cloud Storage. The storage page at /_flin/storage provides visibility into whichever backend is configured.
The API endpoint returns the file list with metadata:
rustpub fn get_storage_stats(storage: &StorageManager) -> Response {
let files = storage.list_files();
let total_size: u64 = files.iter().map(|f| f.size).sum();
let file_list: Vec<serde_json::Value> = files.iter().map(|f| {
json!({
"name": f.name,
"size": f.size,
"size_formatted": format_bytes(f.size),
"content_type": f.content_type,
"created_at": f.created_at.to_rfc3339(),
"storage_backend": f.backend,
})
}).collect();
json_response(200, &json!({
"files": file_list,
"total_files": files.len(),
"total_size": total_size,
"total_size_formatted": format_bytes(total_size),
"backend": storage.current_backend(),
"backends_available": ["local", "s3", "r2", "gcs"],
}))
}The frontend renders this as a file list table with columns for name, size, content type, and upload date. Upload statistics appear above the table: total files, total size, and the active storage backend.
flin// Storage page layout
<div class="storage-page">
<div class="stats-row">
<div class="stat-card">
<span class="stat-label">Total Files</span>
<span class="stat-value">{storage.total_files}</span>
</div>
<div class="stat-card">
<span class="stat-label">Total Size</span>
<span class="stat-value">{storage.total_size_formatted}</span>
</div>
<div class="stat-card">
<span class="stat-label">Backend</span>
<span class="stat-value">{storage.backend}</span>
</div>
</div>
<table class="file-table">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Uploaded</th>
</tr>
</thead>
<tbody>
{for file in storage.files}
<tr>
<td>{file.name}</td>
<td>{file.size_formatted}</td>
<td>{file.content_type}</td>
<td>{file.created_at}</td>
</tr>
{/for}
</tbody>
</table>
</div>The planned roadmap for the storage page includes drag-and-drop upload, file preview (images, PDFs, text files), bucket management for cloud backends, and shareable download links. But the current implementation already answers the most common developer question: "Did my file upload work?"
Database Backup Management
FLIN's embedded database uses a Write-Ahead Log (WAL) for durability. The backup system creates WAL snapshots that can be restored to recover data. The backup page at /_flin/backups manages these snapshots.
rustpub fn get_backups() -> Response {
let backup_dir = Path::new(".flindb/backups");
let mut backups = Vec::new();
if backup_dir.exists() {
for entry in fs::read_dir(backup_dir)? {
let entry = entry?;
let metadata = entry.metadata()?;
backups.push(json!({
"name": entry.file_name().to_string_lossy(),
"size": metadata.len(),
"size_formatted": format_bytes(metadata.len()),
"created_at": metadata.created()
.map(|t| format_timestamp(t))
.unwrap_or_default(),
}));
}
}
// Sort by creation date, newest first
backups.sort_by(|a, b| {
b["created_at"].as_str().cmp(&a["created_at"].as_str())
});
let db_size = calculate_db_size();
json_response(200, &json!({
"backups": backups,
"total_backups": backups.len(),
"database_size": format_bytes(db_size),
}))
}Three operations are available from the UI:
- Create Backup -- triggers a WAL snapshot. The backend captures the current database state into a timestamped file in
.flindb/backups/.
- Delete Backup -- removes a specific backup file. A confirmation dialog prevents accidental deletion.
- View Backup List -- shows all backups with their sizes and timestamps.
rustpub fn create_backup(db: &ZeroCore) -> Response {
let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
let backup_name = format!("backup-{}.wal", timestamp);
let backup_path = format!(".flindb/backups/{}", backup_name);
// Ensure backup directory exists
fs::create_dir_all(".flindb/backups")?;
// Create WAL snapshot
db.create_snapshot(&backup_path)?;
let metadata = fs::metadata(&backup_path)?;
json_response(201, &json!({
"success": true,
"backup": {
"name": backup_name,
"size_formatted": format_bytes(metadata.len()),
"created_at": Utc::now().to_rfc3339(),
}
}))
}The system also supports automatic backups: on WAL checkpoint events, FLIN creates a backup automatically if more than an hour has passed since the last one. A maximum of five automatic backups are retained, with the oldest being deleted when a new one is created. This means every FLIN application has continuous backup protection without any developer configuration.
The Schema Visualizer: ER Diagrams
The Schema page at /_flin/schema renders an Entity-Relationship diagram showing all entities, their fields, and their relationships. It is a visual map of the application's data model.
Session 300 fixed a critical bug in this page. The entity list API (/_flin/api/entities) returned fields as a number (the count of fields), but the JavaScript expected fields to be an array of field objects. The fix used a parallel-fetch strategy:
flin// Schema page: fetch detailed field info for ER diagram
fn fetch_schema() {
// Step 1: Get entity list (fast, returns counts only)
entities = fetch("/_flin/api/entities").json().entities
// Step 2: Fetch detailed schema for each entity (parallel)
detail_promises = entities.map(entity =>
fetch("/_flin/api/entities/" + entity.name)
.then(r => r.json())
.catch(() => none)
)
details = await promise_all(detail_promises)
// Step 3: Merge detailed fields into entity objects
for i in 0..entities.len {
if details[i] != none && is_array(details[i].fields) {
entities[i].fields = details[i].fields
}
}
// Step 4: Render ER diagram
render_er_diagram(entities)
}The Promise.all approach fetches all entity details in parallel, keeping the total load time proportional to the slowest single entity rather than the sum of all entities. For an application with 90 entities, this reduces the schema page load from several seconds (serial) to under 200 milliseconds (parallel).
The ER diagram renders each entity as a card with the entity name as the header and fields listed below with their types. Relationships between entities (detected from field types that reference other entities) are drawn as connecting lines.
flin// ER diagram entity card
fn render_entity_card(entity) {
<div class="er-entity-card" id="entity-{entity.name}">
<div class="er-entity-header">
<h3>{entity.name}</h3>
<span class="record-badge">{entity.record_count} records</span>
</div>
<div class="er-entity-fields">
{for field in entity.fields}
<div class="er-field-row">
<span class="field-name">{field.name}</span>
<span class="field-type">{field.field_type}</span>
{if field.required}
<span class="required-badge">required</span>
{/if}
</div>
{/for}
</div>
</div>
}The schema page also includes a "New Entity" button that navigates to /_flin/entities#create, opening the entity creation modal directly. This creates a smooth workflow: inspect the schema, identify a missing entity, create it, and see it appear in the diagram.
Connecting UI to Reality
Session 252's planning document identified the core problem clearly: the backend was complete, the frontend was a mockup. The solution was not to rebuild either side but to connect them with API calls.
This pattern repeated across every console page:
| Page | Backend (existed) | Frontend (needed) | Bridge (API call) |
|---|---|---|---|
| Storage | StorageManager::list_files() | File table | GET /_flin/api/storage |
| Backups | ZeroCore::create_snapshot() | Backup list + Create button | GET/POST /_flin/api/backups |
| Schema | EntityRegistry::all() | ER diagram | GET /_flin/api/entities/:name |
| Entities | ZeroCore::query() | Records table | GET /_flin/api/entities/:name/records |
| Settings | FlinConfig | Config display | GET /_flin/api/settings/general |
The pattern is consistent: read the Rust struct that holds the data, serialize it to JSON, return it from an API endpoint, fetch it in the frontend JavaScript, and render it into the DOM. No framework needed. No state management library needed. Just fetch(), JSON.parse(), and innerHTML.
The Settings Page
The Settings page at /_flin/settings is divided into three sections:
General Settings display read-only application configuration: app name (from flin.config), FLIN version, server mode (dev/prod), project path, port, and host.
Server Settings show the network configuration: which port the server listens on and which host it binds to.
Security Settings provide the admin account management described in Article 139: email display, password change, and 2FA toggle.
rustpub fn get_general_settings(config: &FlinConfig) -> Response {
json_response(200, &json!({
"app_name": config.name.as_deref().unwrap_or("FLIN App"),
"version": env!("CARGO_PKG_VERSION"),
"mode": if cfg!(debug_assertions) { "development" } else { "production" },
"project_path": config.project_path,
"port": config.port.unwrap_or(3000),
"host": config.host.as_deref().unwrap_or("127.0.0.1"),
}))
}The settings are intentionally read-only for non-security items. Changing the port or host requires editing flin.config and restarting the server. Exposing those settings as editable in the console would create a confusing situation where the console could restart the server mid-request.
The Principle: Real Data or No Data
Session 320 established a firm principle: every console page must show real data. If real data is not available, the page must show an honest empty state rather than mock data. The Realtime Inspector, for example, shows "0 active connections" and "0 channels" when no WebSocket connections exist. This is correct and honest. A mock data approach that showed "23 active connections" would mislead the developer into thinking their application had WebSocket activity when it did not.
This principle -- real data or honest empty states -- is what separates a debugging tool from a demo. The admin console is a debugging tool.
The next article explores how entity history and temporal views bring FLIN's time-travel database capabilities into the admin console.
This is Part 143 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO connected a beautiful frontend to a powerful backend, one API call at a time.
Series Navigation: - [142] Entity Management Enhancements - [143] Storage and Database Admin Views (you are here) - [144] Entity History and Temporal Views in Admin