Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 170 additions & 3 deletions fenn/dashboard/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""Fenn Dashboard — Flask application for browsing fnxml log files."""

from __future__ import annotations

import argparse
Expand All @@ -24,8 +22,10 @@
url_for,
)
from flask_wtf.csrf import CSRFError, CSRFProtect
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import HTTPException
from whenever import PlainDateTime
from werkzeug.utils import secure_filename
from whenever import Instant, PlainDateTime

from fenn.cli.list import get_available_templates
from fenn.cli.pull import pull_template
Expand Down Expand Up @@ -65,6 +65,8 @@
from scanner import FennScanner # ty: ignore[unresolved-import]

_HERE = Path(__file__).parent
_DEFAULT_UPLOAD_DIR = Path.home() / ".fenn" / "uploads"
_DEFAULT_MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB

app = Flask(
__name__,
Expand All @@ -82,6 +84,8 @@
SESSION_COOKIE_SECURE=False, # localhost-only — HTTPS not in scope
PERMANENT_SESSION_LIFETIME=timedelta(hours=12),
WTF_CSRF_TIME_LIMIT=None,
UPLOAD_FOLDER=_DEFAULT_UPLOAD_DIR,
MAX_CONTENT_LENGTH=_DEFAULT_MAX_UPLOAD_SIZE,
)

# CSRF on /connect/start and /logout. Even though we listen on 127.0.0.1, any
Expand Down Expand Up @@ -181,6 +185,149 @@ def _parse_int_arg(
return v


# --------------------------------------------------------------------------- #
# Helper Functions
# --------------------------------------------------------------------------- #


def _list_uploaded_files(
upload_directory: Path,
) -> list[dict[str, str | int]]:
upload_directory.mkdir(parents=True, exist_ok=True)

files: list[dict[str, str | int]] = []

for path in upload_directory.iterdir():
if not path.is_file():
continue

stat = path.stat()
modified_at = (
Instant.from_timestamp(stat.st_mtime)
.round("second")
.format_iso(unit="second")
)

files.append(
{
"filename": path.name,
"size": stat.st_size,
"modified_at": modified_at,
}
)

files.sort(
key=lambda file: str(file["modified_at"]),
reverse=True,
)

return files


def _save_uploaded_file(
upload_directory: Path,
uploaded_file: FileStorage,
filename: str,
) -> Path:
upload_directory.mkdir(parents=True, exist_ok=True)

destination = upload_directory / filename

if destination.exists():
raise FileExistsError(filename)

uploaded_file.save(destination)

return destination


def _validate_upload_filename(
uploaded_file: FileStorage,
) -> str | tuple[Response, int]:
raw_filename = uploaded_file.filename

if raw_filename is None or raw_filename.strip() == "":
return _api_error(
"invalid_file",
"A filename is required",
"file",
)

filename = secure_filename(raw_filename)

if not filename:
return _api_error(
"invalid_filename",
"The filename is not valid",
"file",
)

return filename


def _handle_list_uploads(
upload_directory: Path,
) -> tuple[Response, int] | Response:
try:
files = _list_uploaded_files(upload_directory)
except OSError as exc:
return filesystem_error(exc), 500

return jsonify(
{
"files": files,
"total": len(files),
}
)


def _handle_file_upload(
upload_directory: Path,
) -> tuple[Response, int] | Response:
uploaded_file = request.files.get("file")

if uploaded_file is None:
return _api_error(
"missing_file",
"No file was provided",
"file",
)

filename = _validate_upload_filename(uploaded_file)

if not isinstance(filename, str):
return filename

try:
destination = _save_uploaded_file(
upload_directory,
uploaded_file,
filename,
)
except FileExistsError:
return (
error_response(
"file_exists",
f"A file named '{filename}' already exists",
"file",
),
409,
)
except OSError as exc:
return filesystem_error(exc), 500

return (
jsonify(
{
"uploaded": True,
"filename": filename,
"size": destination.stat().st_size,
}
),
201,
)


@app.before_request
def _require_login() -> werkzeug.wrappers.response.Response | None:
endpoint = request.endpoint
Expand Down Expand Up @@ -231,6 +378,26 @@ def short_id_filter(session_id: str) -> str:
# --------------------------------------------------------------------------- #
# Routes
# --------------------------------------------------------------------------- #
@app.route("/uploads")
def uploads_page() -> str:
"""Render the dashboard file upload page."""
return render_template(
"uploads.html",
projects=scanner.get_overview()["projects"],
active_page="uploads",
)


@app.route("/api/uploads", methods=["GET", "POST"])
def api_upload_file() -> tuple[Response, int] | Response:
"""List uploaded files or upload a new file."""

upload_directory = Path(app.config["UPLOAD_FOLDER"])

if request.method == "GET":
return _handle_list_uploads(upload_directory)

return _handle_file_upload(upload_directory)


@app.route("/")
Expand Down
124 changes: 124 additions & 0 deletions fenn/dashboard/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -668,3 +668,127 @@
updateCount();

})();

const uploadForm = document.getElementById("upload-form");

if (uploadForm) {
const fileInput = document.getElementById("upload-file");
const submitButton = document.getElementById("upload-submit");
const statusElement = document.getElementById("upload-status");
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");

uploadForm.addEventListener("submit", async (event) => {
event.preventDefault();

if (!fileInput.files.length) {
statusElement.hidden = false;
statusElement.textContent = "Please select a file.";
return;
}

const formData = new FormData();
formData.append("file", fileInput.files[0]);

submitButton.disabled = true;
submitButton.textContent = "Uploading...";

statusElement.hidden = false;
statusElement.textContent = "Uploading file...";

try {
const response = await fetch("/api/uploads", {
method: "POST",
headers: {
"X-CSRFToken": csrfToken,
},
body: formData,
});

const data = await response.json();

if (!response.ok) {
const message =
data?.error?.message || "The file could not be uploaded.";

throw new Error(message);
}

const sizeKb = (data.size / 1024).toFixed(1);

statusElement.textContent =
`Upload successful: ${data.filename} (${sizeKb} KB)`;


uploadForm.reset();
loadUploadedFiles();
} catch (error) {
statusElement.textContent =
error instanceof Error
? error.message
: "The file could not be uploaded.";
} finally {
submitButton.disabled = false;
submitButton.textContent = "Upload File";
}
});
}
async function loadUploadedFiles() {
const uploadsList = document.getElementById("uploads-list");

if (!uploadsList) {
return;
}

try {
const response = await fetch("/api/uploads");
const data = await response.json();

if (!response.ok) {
uploadsList.innerHTML =
"<p>Could not load uploaded files.</p>";
return;
}

if (data.total === 0) {
uploadsList.innerHTML =
"<p>No uploaded files yet.</p>";
return;
}

let html = `
<table>
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
`;

data.files.forEach(file => {
html += `
<tr>
<td>${file.filename}</td>
<td>${(file.size / 1024).toFixed(1)} KB</td>
<td>${file.modified_at}</td>
</tr>
`;
});

html += `
</tbody>
</table>
`;

uploadsList.innerHTML = html;

} catch {
uploadsList.innerHTML =
"<p>Could not load uploaded files.</p>";
}
}
loadUploadedFiles();
6 changes: 5 additions & 1 deletion fenn/dashboard/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@
<span class="nav-icon">▣</span>
<span class="nav-label">Templates</span>
</a>

<a href="{{ url_for('uploads_page') }}"
class="nav-item {% if active_page == 'uploads' %}active{% endif %}">
<span class="nav-icon">⇧</span>
<span class="nav-label">Uploads</span>
</a>
{% if projects %}
<div class="sidebar-section-label" style="margin-top:16px;">Projects</div>
{% for p in projects %}
Expand Down
Loading
Loading