5 Commits

Author SHA1 Message Date
alwin
4dfcc7547f wrote migrations for 001_initial.py 2024-12-04 16:14:35 -06:00
5414ae80a8 Add additional README badges, add /api/version route with TOML loading, toml + types-toml 2024-11-12 15:36:44 -06:00
2f7aea9d25 Disable TanStackRouterDevtools in production, add Wakatime repository badge 2024-11-12 15:13:48 -06:00
5d8621feb5 remove now broken/unnecessary TARGET route prefix 2024-11-10 23:55:00 -06:00
f11d5af7cd Merge pull request #15 from Xevion/0.3.0
## Added

- A release checklist to the `CHANGELOG.md` file, as a reminder for procedure.
- An action workflow for invoking `pytest`, with coverage report generation in CI/CD
- backend: Login & Logout routes
- backend: Rate Limiting via custom `RateLimiter` dependency
- backend: `User` model, `Session` model with migration script
- backend: `Session` model constraints for `token` length, `expiry` & `last_used` timestamps
- backend: `SessionDependency` for easy session validation, enforcement & handling per route
- backend: provided `LOG_JSON_FORMAT` and `LOG_LEVEL` environment variable defaults in `run.sh` development script
- backend: Simple `/health` & `/api/migrations` endpoint tests
- backend: `utc_now` helper function
- backend: `pwdlib[argon2]`, `pytest` (`pytest-cov`, `pytest-xdist`), `limits`, `httpx`, `email-validator` pacakges
- frontend: Re-initialized with `vite` template, setup `@tanstack/router` & `shadcn` components.
- frontend: Added Login & Register page, added basic authentication check with redirect
- frontend: Added Zustand state management, basic login & session API functions with `true-myth` types.
- frontend: Added `zustand`, `true-myth`, `@tanstack/router`, `clsx`, `tailwind-merge` packages

## Changed

- Set `black` formatter line length to 120 characters
- backend: migration squashing threshold to 15
- backend: moved top level `app` routes to `router.misc`

## Removed

- frontend: Most old packages from initial `vite` template
- backend: `IPAddress` Model (definition + DB state via migration) & all related code
2024-11-10 23:49:49 -06:00
11 changed files with 5808 additions and 21 deletions

View File

@@ -1,5 +1,7 @@
# linkpulse # linkpulse
![Website Status](https://img.shields.io/website?url=https%3A%2F%2Flinkpulse.xevion.dev&up_message=online&down_message=down&label=linkpulse) ![Python Version](https://img.shields.io/badge/python-3.12-blue) ![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Flinkpulse.xevion%2Fdev%2Fapi%2Fversion&query=%24.version&label=version) [![wakatime](https://wakatime.com/badge/github/Xevion/linkpulse.svg)](https://wakatime.com/badge/github/Xevion/linkpulse)
A project for monitoring websites, built with FastAPI and React. A project for monitoring websites, built with FastAPI and React.
## Structure ## Structure

View File

@@ -39,8 +39,13 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model @migrator.create_model
class IPAddress(pw.Model): class IPAddress(pw.Model):
ip = pw.CharField(max_length=255, primary_key=True)
lastSeen = pw.DateTimeField() """wrote a generic script... hope one of you can test it for me bc its still not working on my machine"""
ip = pw.CharField(max_length=255, primary_key=True, help_text="The IP address (primary key).")
lastSeen = pw.DateTimeField(help_text="Timestamp of the last activity from this IP address.")
location = pw.CharField(max_length=100, null=True, help_text="Optional location information.")
is_blocked = pw.BooleanField(default=False, help_text="Indicates whether the IP is blocked.")
created_at = pw.DateTimeField(constraints=[pw.SQL("DEFAULT CURRENT_TIMESTAMP")], help_text="Record creation time.")
class Meta: class Meta:
table_name = "ipaddress" table_name = "ipaddress"

View File

@@ -25,25 +25,30 @@ Some examples (model - class or model name)::
""" """
from contextlib import suppress from contextlib import suppress
import peewee as pw import peewee as pw
from peewee_migrate import Migrator from peewee_migrate import Migrator
with suppress(ImportError): with suppress(ImportError):
import playhouse.postgres_ext as pw_pext import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False): def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here.""" """
Add a 'count' field to the 'ipaddress' table.
The new field:
- Name: count
- Type: IntegerField
- Default: 0
"""
migrator.add_fields( migrator.add_fields(
'ipaddress', 'ipaddress',
count=pw.IntegerField(default=0)
count=pw.IntegerField(default=0)) )
def rollback(migrator: Migrator, database: pw.Database, *, fake=False): def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here.""" """
Remove the 'count' field from the 'ipaddress' table.
"""
migrator.remove_fields('ipaddress', 'count') migrator.remove_fields('ipaddress', 'count')

View File

@@ -1,16 +1,42 @@
"""Miscellaneous endpoints for the Linkpulse API.""" """Miscellaneous endpoints for the Linkpulse API."""
from pathlib import Path
from typing import Any from typing import Any
import structlog
import toml
from fastapi import APIRouter from fastapi import APIRouter
from fastapi_cache.decorator import cache from fastapi_cache.decorator import cache
from linkpulse.utilities import get_db from linkpulse.utilities import get_db
logger = structlog.get_logger(__name__)
router = APIRouter() router = APIRouter()
db = get_db() db = get_db()
@router.get("/api/version")
@cache(expire=None)
async def version() -> dict[str, str]:
"""Get the version of the API.
:return: The version of the API.
:rtype: dict[str, str]
"""
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
version = "unknown"
if pyproject_path.exists() and pyproject_path.is_file():
data = toml.load(pyproject_path)
version = data["tool"]["poetry"]["version"]
logger.debug("Version loaded from pyproject.toml", version=version)
else:
version = "error"
return {"version": version}
@router.get("/health") @router.get("/health")
async def health(): async def health():
"""An endpoint to check if the service is running. """An endpoint to check if the service is running.

View File

@@ -1,6 +1,21 @@
import re
from linkpulse.utilities import utc_now from linkpulse.utilities import utc_now
from fastapi.testclient import TestClient
from linkpulse.app import app
def test_utcnow_tz_aware(): def test_utcnow_tz_aware():
dt = utc_now() dt = utc_now()
dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
def test_api_version():
with TestClient(app) as client:
response = client.get("/api/version")
assert response.status_code == 200
assert "version" in response.json()
version = response.json()["version"]
assert isinstance(version, str)
assert re.match(r"^\d+\.\d+\.\d+$", version)

24
backend/poetry.lock generated
View File

@@ -1626,6 +1626,17 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi
tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"]
typing = ["mypy (>=1.4)", "rich", "twisted"] typing = ["mypy (>=1.4)", "rich", "twisted"]
[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
[[package]] [[package]]
name = "types-peewee" name = "types-peewee"
version = "3.17.7.20241017" version = "3.17.7.20241017"
@@ -1659,6 +1670,17 @@ files = [
{file = "types_pytz-2024.2.0.20241003-py3-none-any.whl", hash = "sha256:3e22df1336c0c6ad1d29163c8fda82736909eb977281cb823c57f8bae07118b7"}, {file = "types_pytz-2024.2.0.20241003-py3-none-any.whl", hash = "sha256:3e22df1336c0c6ad1d29163c8fda82736909eb977281cb823c57f8bae07118b7"},
] ]
[[package]]
name = "types-toml"
version = "0.10.8.20240310"
description = "Typing stubs for toml"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-toml-0.10.8.20240310.tar.gz", hash = "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331"},
{file = "types_toml-0.10.8.20240310-py3-none-any.whl", hash = "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d"},
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
@@ -1840,4 +1862,4 @@ h11 = ">=0.9.0,<1"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "9dff2a47bb95e65f15616bb82926eb81d418456c5ec60db46dfe06056c643e31" content-hash = "4e094b6d46fab5f7886452b22c30fc520d396c6d77f9f92dfbd4ef36257c10df"

View File

@@ -5,7 +5,7 @@ description = ""
authors = ["Xevion <xevion@xevion.dev>"] authors = ["Xevion <xevion@xevion.dev>"]
license = "GNU GPL v3" license = "GNU GPL v3"
readme = "README.md" readme = "README.md"
package-mode = false package-mode = true
[tool.poetry.scripts] [tool.poetry.scripts]
app = "linkpulse" app = "linkpulse"
@@ -32,6 +32,8 @@ pwdlib = {extras = ["argon2"], version = "^0.2.1"}
pytest-xdist = "^3.6.1" pytest-xdist = "^3.6.1"
email-validator = "^2.2.0" email-validator = "^2.2.0"
limits = "^3.13.0" limits = "^3.13.0"
toml = "^0.10.2"
types-toml = "^0.10.8.20240310"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]

5700
frontend/package-lock.json generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -46,7 +46,7 @@
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.11.0", "typescript-eslint": "^8.11.0",
"vite": "^5.4.10", "vite": "^5.4.11",
"vitest": "^2.1.4" "vitest": "^2.1.4"
} }
} }

View File

@@ -8,10 +8,6 @@ import { useUserStore } from "@/lib/state";
// If any protected API call suddenly fails with a 401 status code, the user store is reset, a logout message is displayed, and the user is redirected to the login page. // If any protected API call suddenly fails with a 401 status code, the user store is reset, a logout message is displayed, and the user is redirected to the login page.
// All redirects to the login page will carry a masked URL parameter that can be used to redirect the user back to the page they were on after logging in. // All redirects to the login page will carry a masked URL parameter that can be used to redirect the user back to the page they were on after logging in.
const TARGET = !import.meta.env.DEV
? `http://${import.meta.env.VITE_BACKEND_TARGET}`
: "";
type ErrorResponse = { type ErrorResponse = {
detail: string; detail: string;
}; };
@@ -31,7 +27,7 @@ type SessionResponse = {
export const getSession = async (): Promise< export const getSession = async (): Promise<
Result<SessionResponse, ErrorResponse> Result<SessionResponse, ErrorResponse>
> => { > => {
const response = await fetch(TARGET + "/api/session"); const response = await fetch("/api/session");
if (response.ok) { if (response.ok) {
const user = await response.json(); const user = await response.json();
useUserStore.getState().setUser(user); useUserStore.getState().setUser(user);
@@ -62,7 +58,7 @@ export const login = async (
email: string, email: string,
password: string, password: string,
): Promise<Result<LoginResponse, ErrorResponse>> => { ): Promise<Result<LoginResponse, ErrorResponse>> => {
const response = await fetch(TARGET + "/api/login", { const response = await fetch("/api/login", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -1,11 +1,25 @@
import { createRootRoute, Outlet } from "@tanstack/react-router"; import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools"; import { lazy, Suspense } from "react";
const TanStackRouterDevtools =
process.env.NODE_ENV === "production"
? () => null // Render nothing in production
: lazy(() =>
// Lazy load in development
import("@tanstack/router-devtools").then((res) => ({
default: res.TanStackRouterDevtools,
// For Embedded Mode
// default: res.TanStackRouterDevtoolsPanel
})),
);
export const Route = createRootRoute({ export const Route = createRootRoute({
component: () => ( component: () => (
<> <>
<Outlet /> <Outlet />
<Suspense>
<TanStackRouterDevtools /> <TanStackRouterDevtools />
</Suspense>
</> </>
), ),
}); });