generated from erangel1/generic-template
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6ed575b09 | |||
| 04add2eb14 | |||
| 40d3a6b6c1 | |||
| 3468f38532 | |||
| 89e058ffac | |||
| d9c68313a0 | |||
| cb190021c9 | |||
| 722d60a579 |
@@ -0,0 +1,40 @@
|
||||
# =============================================================
|
||||
# LabGraph Environment Variables
|
||||
# Copy this file to .env and fill in all required values.
|
||||
# Values marked (required) have no default and MUST be set.
|
||||
# =============================================================
|
||||
|
||||
# --- Django ---
|
||||
DJANGO_SETTINGS_MODULE=config.settings.development
|
||||
SECRET_KEY=39viQnJn0IF0WZfgPYu3GA68YXufPFzaW_Uiz_tlIzoBmpfe3XnlZVJ2y02ntT0JrTY
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||
|
||||
# --- PostgreSQL (used by both docker-compose and Django) ---
|
||||
POSTGRES_DB=labgraph
|
||||
POSTGRES_USER=labgraph
|
||||
POSTGRES_PASSWORD=labgraph
|
||||
|
||||
# --- Database URL (used by django-environ) ---
|
||||
# Format: postgres://USER:PASSWORD@HOST:PORT/DBNAME
|
||||
DATABASE_URL=postgres://labgraph:labgraph@db:5432/labgraph
|
||||
|
||||
# --- Redis ---
|
||||
# Note: URL format uses colon before password, no username: redis://:PASSWORD@host:port/db
|
||||
REDIS_PASSWORD=QuietProfe55ion@!
|
||||
REDIS_URL=redis://:QuietProfe55ion@!@redis:6379/0
|
||||
|
||||
# --- Celery Broker ---
|
||||
CELERY_BROKER_URL=redis://:QuietProfe55ion@!@redis:6379/0
|
||||
|
||||
# --- CORS ---
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# --- Flower (Celery monitoring dashboard) ---
|
||||
FLOWER_USER=admin
|
||||
FLOWER_PASSWORD=QuietProfe55ion@!
|
||||
|
||||
# --- Discovery Credentials (Phase 2) ---
|
||||
PROXMOX_URL=
|
||||
PROXMOX_TOKEN_NAME=
|
||||
PROXMOX_TOKEN_VALUE=
|
||||
@@ -0,0 +1,40 @@
|
||||
# =============================================================
|
||||
# LabGraph Environment Variables
|
||||
# Copy this file to .env and fill in all required values.
|
||||
# Values marked (required) have no default and MUST be set.
|
||||
# =============================================================
|
||||
|
||||
# --- Django ---
|
||||
DJANGO_SETTINGS_MODULE=config.settings.development
|
||||
SECRET_KEY=change-me-generate-with-python-secrets-token-urlsafe-50
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||
|
||||
# --- PostgreSQL (used by both docker-compose and Django) ---
|
||||
POSTGRES_DB=labgraph
|
||||
POSTGRES_USER=labgraph
|
||||
POSTGRES_PASSWORD=changeme-required
|
||||
|
||||
# --- Database URL (used by django-environ) ---
|
||||
# Format: postgres://USER:PASSWORD@HOST:PORT/DBNAME
|
||||
DATABASE_URL=postgres://labgraph:changeme-required@db:5432/labgraph
|
||||
|
||||
# --- Redis ---
|
||||
# Note: URL format uses colon before password, no username: redis://:PASSWORD@host:port/db
|
||||
REDIS_PASSWORD=changeme-required
|
||||
REDIS_URL=redis://:changeme-required@redis:6379/0
|
||||
|
||||
# --- Celery Broker ---
|
||||
CELERY_BROKER_URL=redis://:changeme-required@redis:6379/0
|
||||
|
||||
# --- CORS ---
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# --- Flower (Celery monitoring dashboard) ---
|
||||
FLOWER_USER=admin
|
||||
FLOWER_PASSWORD=changeme-required
|
||||
|
||||
# --- Discovery Credentials (Phase 2) ---
|
||||
PROXMOX_URL=
|
||||
PROXMOX_TOKEN_NAME=
|
||||
PROXMOX_TOKEN_VALUE=
|
||||
@@ -0,0 +1,122 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
LabGraph is a HomeLab infrastructure documentation engine. It maps physical hardware to virtual services using a graph model (Nodes + Edges). The stack is Django + Celery on the backend, Next.js 14 + DaisyUI on the frontend, all orchestrated via Docker Compose.
|
||||
|
||||
## Running the Stack
|
||||
|
||||
```bash
|
||||
# Start everything (from repo root)
|
||||
docker compose up -d
|
||||
|
||||
# Include Flower (Celery monitoring at localhost:5555)
|
||||
docker compose --profile dev-tools up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f web
|
||||
docker compose logs -f celery-worker
|
||||
|
||||
# Rebuild after Dockerfile or requirements.txt changes
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The web service runs `backend/scripts/entrypoint.sh` which runs migrate → collectstatic → gunicorn.
|
||||
|
||||
## Backend Commands (inside container)
|
||||
|
||||
```bash
|
||||
# Run migrations
|
||||
docker compose exec web python manage.py migrate
|
||||
|
||||
# Create superuser
|
||||
docker compose exec web python manage.py createsuperuser
|
||||
|
||||
# Make migrations after model changes
|
||||
docker compose exec web python manage.py makemigrations
|
||||
|
||||
# Open Django shell
|
||||
docker compose exec web python manage.py shell
|
||||
|
||||
# Inspect Celery workers
|
||||
docker compose exec celery-worker celery -A config.celery inspect ping
|
||||
docker compose exec celery-worker celery -A config.celery inspect active
|
||||
```
|
||||
|
||||
## Frontend Commands
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # Dev server at localhost:3000
|
||||
npm run type-check # TypeScript check (tsc --noEmit)
|
||||
npm run lint # ESLint
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Copy `.env.example` to `.env` (repo root). Copy `frontend/.env.local.example` to `frontend/.env.local`.
|
||||
|
||||
Required variables that have no defaults and will crash on startup if missing:
|
||||
- `SECRET_KEY` — Django secret key (`python3 -c "import secrets; print(secrets.token_urlsafe(50))"`)
|
||||
- `POSTGRES_PASSWORD` — PostgreSQL password (used directly by docker-compose)
|
||||
- `REDIS_PASSWORD` — Redis password (used directly by docker-compose)
|
||||
- `NEXTAUTH_SECRET` — NextAuth JWT signing key (`openssl rand -base64 32`)
|
||||
|
||||
Critical: `DATABASE_URL`, `REDIS_URL`, and `CELERY_BROKER_URL` must use the Docker service hostnames `db` and `redis`, not `localhost`. The Redis URL format is `redis://:PASSWORD@redis:6379/0` (colon before password, no username).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (`backend/`)
|
||||
|
||||
**Settings** are split into `config/settings/base.py` (shared) → `config/settings/development.py` / `production.py`. Settings are loaded via `django-environ` from the root `.env` file (`BASE_DIR.parent / ".env"`). Never import `base` directly — always use `development` or `production`.
|
||||
|
||||
**Celery** is configured in `config/celery.py` and referenced by docker-compose as `-A config.celery`. Three queues: `discovery` (nmap/Proxmox scans), `heartbeat` (ICMP/TCP liveness), `default` (maintenance). Beat uses `DatabaseScheduler`, so periodic tasks are stored in the DB and can be edited at runtime via admin.
|
||||
|
||||
**Graph model** in `apps/core/models.py`:
|
||||
- `Node` — any infrastructure component (location → hardware → hypervisor → VM/container → application). All have UUID PKs, a `node_type` TextChoices enum, `status`, optional `ip_address`, and a `metadata` JSONField for per-type flexible data.
|
||||
- `Edge` — directed source→target relationship with `edge_type` (parent_child, network, dependency, physical). Unique constraint on `(source, target, edge_type)`.
|
||||
- `Network` — VLAN/subnet records (separate from Nodes).
|
||||
- `WikiPage` — OneToOne to Node, stores raw Markdown; `rendered_html()` converts via the `Markdown` package.
|
||||
- `HeartbeatLog` — high-volume time-series; pruned nightly by `tasks/maintenance.py`.
|
||||
|
||||
**Input validation** uses Pydantic v2 schemas (`apps/core/schemas.py`) at the API boundary before data reaches the ORM. DRF serializers (to be added in Phase 2) handle output.
|
||||
|
||||
**Celery tasks** live in `tasks/` (not inside an app). Phase 1 stubs are in `tasks/discovery.py`, `tasks/heartbeat.py`, `tasks/maintenance.py`. Autodiscovery is configured for the `tasks` package.
|
||||
|
||||
**RLS**: `apps/core/apps.py` connects a `post_migrate` signal that enables PostgreSQL Row-Level Security on `core_node`. The handler checks `information_schema.tables` first — it fires after every app's migrations, not just after core.
|
||||
|
||||
**Health check** at `GET /api/health/` (no auth required) checks DB (`SELECT 1`) and Redis (cache round-trip). Returns `{"status": "ok"|"degraded", "services": {...}, "version": "1.0.0"}`. Used by docker-compose healthcheck.
|
||||
|
||||
**OpenAPI schema** at `GET /api/schema/`, Swagger UI at `GET /api/docs/`.
|
||||
|
||||
### Frontend (`frontend/`)
|
||||
|
||||
Next.js 14 App Router. TypeScript strict mode with `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes` — avoid `any`.
|
||||
|
||||
**Auth flow**: NextAuth (`src/lib/auth.ts`) uses `CredentialsProvider`. In development, any non-empty credentials are accepted (stub). Phase 2 wires `authorize()` to `POST /api/auth/login/` on the Django backend. `NEXTAUTH_SECRET` must be stable in `.env.local` — changing it invalidates all existing sessions.
|
||||
|
||||
**API client** (`src/lib/api.ts`): all backend calls go through `apiFetch()` which validates responses against Zod schemas before returning. Throws `ApiError` on non-2xx, `ZodError` on schema mismatch.
|
||||
|
||||
**Types** (`src/types/index.ts`): Zod schemas are the source of truth; TypeScript types are inferred with `z.infer<>`. The schemas mirror the Django models exactly — keep them in sync when models change.
|
||||
|
||||
**Routing**: `next.config.mjs` proxies `/backend/*` → Django. Route groups: `(auth)` for login, `(dashboard)` for protected pages. Auth guard is done server-side with `getServerSession(authOptions)` + `redirect()`.
|
||||
|
||||
**Styling**: Tailwind CSS + DaisyUI. Theme is set via `html[data-theme]` in `layout.tsx`. DaisyUI component classes (e.g. `btn`, `card`, `alert`) — no custom CSS unless DaisyUI can't cover it.
|
||||
|
||||
## Phase Status
|
||||
|
||||
- **Phase 1** ✅ Complete — Docker stack, Django models, Celery stubs, Next.js scaffold
|
||||
- **Phase 2** 🔄 In Progress — DRF ViewSets, discovery scrapers (Proxmox/nmap), heartbeat (icmplib), Django auth endpoint
|
||||
- **Phase 3** ⏳ Pending — Nested inventory list, React Flow topology graph, Markdown wiki editor
|
||||
|
||||
## Key Constraints
|
||||
|
||||
- The `requirements.txt` lives in `backend/` (Docker build context is `./backend`).
|
||||
- `next.config.ts` is not supported in Next.js 14 — use `next.config.mjs`.
|
||||
- YAML `>` folded scalars in docker-compose do NOT fold newlines on more-indented continuation lines — use a shell script instead of multi-line `sh -c` commands.
|
||||
- `HeartbeatLog` is high-volume — always filter by `node` and use the `(node, -timestamp)` index; never do full-table scans.
|
||||
- `Node.metadata` is a free-form JSONField — validate its contents with the Pydantic schemas in `apps/core/schemas.py` before writing.
|
||||
@@ -1,3 +1,105 @@
|
||||
# Template Repository
|
||||
# 📊 LabGraph
|
||||
|
||||
Hello, this is a basic template to use when creating new repos.
|
||||
**A streamlined, graph-based infrastructure documentation engine designed specifically for HomeLab enthusiasts to map physical hardware to virtual services.**[cite: 1]
|
||||
|
||||
---
|
||||
|
||||
## 🚀 The Vision
|
||||
|
||||
Documentation in a HomeLab often falls into two extremes: overly complex enterprise tools like NetBox or static notes that quickly become outdated[cite: 1]. **LabGraph** bridges this gap by prioritizing automated discovery, visual clarity, and context-aware documentation[cite: 1]. It allows you to see the exact relationship between a physical server in your rack and the digital services running within it[cite: 1, 2].
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### 🔍 Automated Discovery & Monitoring
|
||||
|
||||
* **Hypervisor/Runtime Sync:** Background workers pull real-time inventory from Proxmox VE, Docker, and TrueNAS APIs[cite: 1, 2].
|
||||
* **Active Monitoring:** Integrated heartbeat service performing ICMP pings and HTTP/HTTPS status checks for live "Up/Down" indicators[cite: 2].
|
||||
* **Network Scanning:** Scheduled tasks to scan subnets and identify undocumented "rogue" devices via Nmap[cite: 2].
|
||||
|
||||
### 🗺️ Visualization Suite
|
||||
|
||||
* **Hierarchical "Drill-Down":** A nested accordion list view showing the chain from Physical Location → Hardware Host → Virtualization Layer → VM/Container → Application[cite: 1, 2].
|
||||
* **Interactive Topology Map:** A dynamic network graph powered by **React Flow** that auto-arranges nodes based on gateway relationships[cite: 2].
|
||||
* **Contextual Sidebar:** Click any node to see technical specs (IP, MAC, Port), resource sparklines, and documentation instantly[cite: 2].
|
||||
|
||||
### 📖 Knowledge & Lifecycle Management
|
||||
|
||||
* **Integrated Markdown Wiki:** Every node has an associated "Sidecar" Markdown file for technical notes, rendered directly in the detail view[cite: 1, 2].
|
||||
* **Maintenance Logs:** A chronological event ledger to track hardware swaps, thermal paste changes, and upgrades[cite: 1, 2].
|
||||
* **Global Command Palette:** Use `Ctrl + K` to search for any device, IP, or wiki entry instantly[cite: 2].
|
||||
|
||||
### ⚡ HomeLab Utilities
|
||||
|
||||
* **Power & Cost Estimator:** Input wattage for hardware to calculate total rack draw and monthly electricity costs[cite: 1, 2].
|
||||
* **IPAM:** Dedicated tracker for VLAN IDs, CIDR ranges, and IP exhaustion[cite: 1, 2].
|
||||
* **Public Status Page:** A toggleable, read-only dashboard for family members to check service status[cite: 1, 2].
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Frontend:** React, TypeScript, Tailwind CSS (DaisyUI), React Flow[cite: 1, 3].
|
||||
* **Backend:** Python, Django Rest Framework (DRF)[cite: 1, 3].
|
||||
* **Task Queue:** Celery & Redis (for background scans and heartbeats).
|
||||
* **Database:** PostgreSQL[cite: 1, 3].
|
||||
* **Security:** NextAuth.js, OWASP Top 10 compliance, and strict validation via Pydantic/Zod.
|
||||
|
||||
---
|
||||
|
||||
## 💻 Getting Started (Dev Container)
|
||||
|
||||
This project is optimized for development using **VS Code Dev Containers**.
|
||||
|
||||
1. **Clone the repository:**
|
||||
|
||||
```bash
|
||||
git clone [https://github.com/your-username/LabGraph.git](https://github.com/your-username/LabGraph.git)
|
||||
cd LabGraph
|
||||
|
||||
2. **Open in VS Code:**
|
||||
|
||||
```bash
|
||||
code .
|
||||
```
|
||||
|
||||
3. **Reopen in Container:**
|
||||
|
||||
When prompted, select **"Reopen in Container"** to build the environment (includes Python, Node.js, PostgreSQL, and Redis).
|
||||
|
||||
4. **Initialize the Environment:**
|
||||
|
||||
Once the container is running, execute the following in the terminal:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
npm install
|
||||
|
||||
# Run migrations
|
||||
python manage.py migrate
|
||||
|
||||
# Start development servers
|
||||
python manage.py runserver 0.0.0.0:8000 &
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Branding & Identity
|
||||
|
||||
The LabGraph logo is a geometric Connected Node Hexagon.
|
||||
|
||||
- The Shape: A hexagon representing a single data node.
|
||||
|
||||
- The Interior: Three horizontal bars representing a stylized server rack.
|
||||
|
||||
- The Connectivity: Circuit-like lines branching out to represent the network graph and data flow[cite: 3].
|
||||
|
||||
- Color Palette: GitHub Dark theme inspired (Deep Charcoal #0d1117, Electric Blue #58a6ff, and Success Green #238636)[cite: 3].
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
Distributed under the MIT License. See LICENSE for more information[cite: 3].
|
||||
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
staticfiles/
|
||||
mediafiles/
|
||||
wiki_store/
|
||||
*.sqlite3
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
htmlcov/
|
||||
@@ -0,0 +1,64 @@
|
||||
# =============================================================================
|
||||
# LabGraph — Multi-stage Dockerfile
|
||||
# Stages: base → development → production
|
||||
# docker-compose uses target: development for all services
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# base — shared OS deps, non-root user, Python dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# curl: required by docker-compose web healthcheck (curl http://localhost:8000/api/health/)
|
||||
# nmap: required by python-nmap discovery tasks (Phase 2)
|
||||
# libpq-dev + build-essential: required to compile psycopg[binary]
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
curl \
|
||||
nmap \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --gid 1000 appuser && \
|
||||
useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# development — volume-mounted source, no CMD (docker-compose provides it)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS development
|
||||
|
||||
RUN pip install watchdog
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# production — source baked in, runs as non-root, gunicorn CMD
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS production
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "config.wsgi:application", \
|
||||
"--bind", "0.0.0.0:8000", \
|
||||
"--workers", "4", \
|
||||
"--worker-class", "gthread", \
|
||||
"--threads", "2", \
|
||||
"--timeout", "120", \
|
||||
"--access-logfile", "-", \
|
||||
"--error-logfile", "-"]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Django admin registration for all core models."""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Edge, HeartbeatLog, Network, Node, WikiPage
|
||||
|
||||
|
||||
@admin.register(Node)
|
||||
class NodeAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("label", "node_type", "status", "ip_address", "discovery_source", "updated_at")
|
||||
list_filter = ("node_type", "status", "discovery_source")
|
||||
search_fields = ("label", "ip_address", "mac_address", "external_id")
|
||||
readonly_fields = ("id", "created_at", "updated_at")
|
||||
ordering = ("label",)
|
||||
list_per_page = 50
|
||||
|
||||
|
||||
@admin.register(Edge)
|
||||
class EdgeAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("source", "target", "edge_type", "weight", "created_at")
|
||||
list_filter = ("edge_type",)
|
||||
raw_id_fields = ("source", "target")
|
||||
readonly_fields = ("id", "created_at")
|
||||
list_per_page = 50
|
||||
|
||||
|
||||
@admin.register(Network)
|
||||
class NetworkAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("name", "cidr", "vlan_id", "gateway", "created_at")
|
||||
search_fields = ("name", "cidr")
|
||||
readonly_fields = ("id", "created_at", "updated_at")
|
||||
list_per_page = 50
|
||||
|
||||
|
||||
@admin.register(WikiPage)
|
||||
class WikiPageAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("node", "last_edited_by", "updated_at")
|
||||
raw_id_fields = ("node", "last_edited_by")
|
||||
readonly_fields = ("id", "created_at", "updated_at")
|
||||
list_per_page = 50
|
||||
|
||||
|
||||
@admin.register(HeartbeatLog)
|
||||
class HeartbeatLogAdmin(admin.ModelAdmin): # type: ignore[type-arg]
|
||||
list_display = ("node", "status", "check_type", "response_time_ms", "timestamp")
|
||||
list_filter = ("status", "check_type")
|
||||
raw_id_fields = ("node",)
|
||||
readonly_fields = ("id", "timestamp")
|
||||
date_hierarchy = "timestamp"
|
||||
list_per_page = 100
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Core app configuration.
|
||||
|
||||
Connects a post_migrate signal to enable PostgreSQL Row-Level Security
|
||||
on the core_node table. The policy is permissive for now (allow all);
|
||||
Phase 2+ can tighten it for multi-user support.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "apps.core"
|
||||
verbose_name = "Infrastructure Core"
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
|
||||
def ready(self) -> None:
|
||||
from django.db.models.signals import post_migrate
|
||||
|
||||
post_migrate.connect(_enable_node_rls, sender=self)
|
||||
|
||||
|
||||
def _enable_node_rls(sender: AppConfig, **kwargs: object) -> None:
|
||||
"""Enable PostgreSQL RLS on the node table after migrations complete."""
|
||||
from django.db import connection
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# Bail out early if core_node doesn't exist yet (fires before core migrations)
|
||||
cursor.execute(
|
||||
"SELECT EXISTS ("
|
||||
" SELECT 1 FROM information_schema.tables WHERE table_name = 'core_node'"
|
||||
");"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None or not row[0]:
|
||||
return
|
||||
cursor.execute("ALTER TABLE core_node ENABLE ROW LEVEL SECURITY;")
|
||||
cursor.execute(
|
||||
"DO $$ BEGIN "
|
||||
" IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'core_node' AND policyname = 'allow_all_for_now') THEN "
|
||||
" CREATE POLICY allow_all_for_now ON core_node USING (true) WITH CHECK (true); "
|
||||
" END IF; "
|
||||
"END $$;"
|
||||
)
|
||||
except (OperationalError, ProgrammingError):
|
||||
logger.debug("core_node table not yet available for RLS setup; will apply on next migrate.")
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Core graph models for LabGraph.
|
||||
|
||||
Graph structure:
|
||||
Node — vertices (any infrastructure component)
|
||||
Edge — directed relationships between nodes
|
||||
Network — IP network segments (VLANs/subnets)
|
||||
WikiPage — Markdown documentation anchored to a node
|
||||
HeartbeatLog — time-series liveness records per node
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import markdown as md
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Node(models.Model):
|
||||
"""
|
||||
Polymorphic infrastructure node — the vertex type in the LabGraph.
|
||||
|
||||
node_type drives rendering and determines what metadata keys are
|
||||
expected in the JSONField. Pydantic schemas in schemas.py validate
|
||||
metadata content at the API boundary before writes reach the ORM.
|
||||
"""
|
||||
|
||||
class NodeType(models.TextChoices):
|
||||
LOCATION = "location", "Physical Location"
|
||||
HARDWARE = "hardware", "Physical Hardware"
|
||||
HYPERVISOR = "hypervisor", "Hypervisor Host"
|
||||
VM = "vm", "Virtual Machine"
|
||||
CONTAINER = "container", "Container"
|
||||
APPLICATION = "application", "Application / Service"
|
||||
NETWORK_DEVICE = "network_device", "Network Device"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
ONLINE = "online", "Online"
|
||||
OFFLINE = "offline", "Offline"
|
||||
DEGRADED = "degraded", "Degraded"
|
||||
UNKNOWN = "unknown", "Unknown"
|
||||
MAINTENANCE = "maintenance", "Maintenance"
|
||||
|
||||
class DiscoverySource(models.TextChoices):
|
||||
MANUAL = "manual", "Manually Added"
|
||||
PROXMOX = "proxmox", "Proxmox API"
|
||||
NMAP = "nmap", "Nmap Scan"
|
||||
SNMP = "snmp", "SNMP"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
label = models.CharField(max_length=255, db_index=True)
|
||||
node_type = models.CharField(
|
||||
max_length=30, choices=NodeType.choices, db_index=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.UNKNOWN,
|
||||
db_index=True,
|
||||
)
|
||||
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True, db_index=True)
|
||||
# Stored as colon-separated hex: "aa:bb:cc:dd:ee:ff"
|
||||
mac_address = models.CharField(max_length=17, null=True, blank=True)
|
||||
|
||||
# Rated power draw in watts — used by the power cost estimator (Phase 3)
|
||||
wattage = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
|
||||
|
||||
# Flexible per-type data; validated by Pydantic at the API boundary
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
discovery_source = models.CharField(
|
||||
max_length=20,
|
||||
choices=DiscoverySource.choices,
|
||||
default=DiscoverySource.MANUAL,
|
||||
)
|
||||
# ID in the originating external system, e.g. Proxmox VMID or Docker container ID
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["label"]
|
||||
indexes = [
|
||||
models.Index(fields=["node_type", "status"]),
|
||||
models.Index(fields=["discovery_source", "external_id"]),
|
||||
]
|
||||
verbose_name = "Node"
|
||||
verbose_name_plural = "Nodes"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.label} ({self.get_node_type_display()})"
|
||||
|
||||
|
||||
class Edge(models.Model):
|
||||
"""
|
||||
Directed edge between two Nodes in the infrastructure graph.
|
||||
|
||||
Direction: source → target.
|
||||
Multiple edge types allow different relationship semantics to coexist
|
||||
(e.g. a VM has a parent_child edge to its hypervisor AND a network
|
||||
edge to a switch).
|
||||
"""
|
||||
|
||||
class EdgeType(models.TextChoices):
|
||||
PARENT_CHILD = "parent_child", "Parent / Child"
|
||||
NETWORK = "network", "Network Connection"
|
||||
DEPENDENCY = "dependency", "Software Dependency"
|
||||
PHYSICAL = "physical", "Physical Connection"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
source = models.ForeignKey(
|
||||
Node, on_delete=models.CASCADE, related_name="outgoing_edges"
|
||||
)
|
||||
target = models.ForeignKey(
|
||||
Node, on_delete=models.CASCADE, related_name="incoming_edges"
|
||||
)
|
||||
edge_type = models.CharField(
|
||||
max_length=20, choices=EdgeType.choices, db_index=True
|
||||
)
|
||||
|
||||
weight = models.FloatField(default=1.0)
|
||||
label = models.CharField(max_length=255, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("source", "target", "edge_type")]
|
||||
indexes = [models.Index(fields=["edge_type"])]
|
||||
verbose_name = "Edge"
|
||||
verbose_name_plural = "Edges"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.source.label} → {self.target.label} [{self.edge_type}]"
|
||||
|
||||
|
||||
class Network(models.Model):
|
||||
"""
|
||||
An IP network segment that nodes attach to.
|
||||
|
||||
Nodes reference Networks via metadata or a future ManyToMany through-model
|
||||
(Phase 2 IPAM). Networks are separate entities from Nodes so that VLAN
|
||||
and subnet data can be managed independently.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
vlan_id = models.PositiveSmallIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="802.1Q VLAN tag (1–4094)",
|
||||
)
|
||||
# CIDR notation, e.g. "192.168.10.0/24"
|
||||
cidr = models.CharField(max_length=43, db_index=True)
|
||||
gateway = models.GenericIPAddressField(null=True, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["cidr"]
|
||||
verbose_name = "Network"
|
||||
verbose_name_plural = "Networks"
|
||||
|
||||
def __str__(self) -> str:
|
||||
vlan_str = f" (VLAN {self.vlan_id})" if self.vlan_id else ""
|
||||
return f"{self.name} {self.cidr}{vlan_str}"
|
||||
|
||||
|
||||
class WikiPage(models.Model):
|
||||
"""
|
||||
Human-authored documentation anchored to a single Node.
|
||||
|
||||
Content is stored as raw Markdown. rendered_html() converts it to
|
||||
safe HTML at render time using the Markdown package.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
node = models.OneToOneField(
|
||||
Node, on_delete=models.CASCADE, related_name="wiki_page"
|
||||
)
|
||||
content = models.TextField(blank=True, help_text="Markdown content")
|
||||
last_edited_by = models.ForeignKey(
|
||||
"auth.User",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="wiki_edits",
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Wiki Page"
|
||||
verbose_name_plural = "Wiki Pages"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Wiki: {self.node.label}"
|
||||
|
||||
def rendered_html(self) -> str:
|
||||
"""Convert Markdown content to safe HTML."""
|
||||
return md.markdown(
|
||||
self.content,
|
||||
extensions=["fenced_code", "tables", "nl2br"],
|
||||
)
|
||||
|
||||
|
||||
class HeartbeatLog(models.Model):
|
||||
"""
|
||||
Time-series liveness record for a Node.
|
||||
|
||||
Written by tasks/heartbeat.py on every check cycle. High-volume —
|
||||
records accumulate fast. The maintenance task prunes rows older than
|
||||
30 days nightly.
|
||||
"""
|
||||
|
||||
class CheckType(models.TextChoices):
|
||||
ICMP = "icmp", "ICMP Ping"
|
||||
TCP = "tcp", "TCP Port Check"
|
||||
HTTP = "http", "HTTP/S Check"
|
||||
SNMP = "snmp", "SNMP Poll"
|
||||
|
||||
class HeartbeatStatus(models.TextChoices):
|
||||
UP = "up", "Up"
|
||||
DOWN = "down", "Down"
|
||||
TIMEOUT = "timeout", "Timeout"
|
||||
ERROR = "error", "Error"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
node = models.ForeignKey(
|
||||
Node, on_delete=models.CASCADE, related_name="heartbeat_logs"
|
||||
)
|
||||
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
status = models.CharField(
|
||||
max_length=10, choices=HeartbeatStatus.choices, db_index=True
|
||||
)
|
||||
check_type = models.CharField(max_length=10, choices=CheckType.choices)
|
||||
# Null on timeout or error — response never completed
|
||||
response_time_ms = models.PositiveIntegerField(null=True, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-timestamp"]
|
||||
indexes = [
|
||||
models.Index(fields=["node", "-timestamp"]),
|
||||
models.Index(fields=["status", "-timestamp"]),
|
||||
]
|
||||
verbose_name = "Heartbeat Log"
|
||||
verbose_name_plural = "Heartbeat Logs"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.node.label} [{self.status}] @ {self.timestamp:%Y-%m-%d %H:%M:%S}"
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Pydantic v2 input validation schemas for the LabGraph API.
|
||||
|
||||
These schemas validate incoming request data at the API boundary BEFORE
|
||||
it reaches the Django ORM. A ValidationError here is converted to an
|
||||
HTTP 400 in the view layer (Phase 2).
|
||||
|
||||
Keeping validation here (not in DRF serializers) means the same schemas
|
||||
can be reused by Celery discovery tasks when upserting auto-discovered nodes.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
_MAC_PATTERN = re.compile(r"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$")
|
||||
|
||||
VALID_NODE_TYPES = {
|
||||
"location", "hardware", "hypervisor", "vm",
|
||||
"container", "application", "network_device",
|
||||
}
|
||||
|
||||
VALID_EDGE_TYPES = {"parent_child", "network", "dependency", "physical"}
|
||||
|
||||
VALID_DISCOVERY_SOURCES = {"manual", "proxmox", "nmap", "snmp"}
|
||||
|
||||
|
||||
class NodeCreateSchema(BaseModel):
|
||||
"""Validates POST /api/v1/nodes/ request body."""
|
||||
|
||||
label: str = Field(min_length=1, max_length=255)
|
||||
node_type: str
|
||||
ip_address: str | None = None
|
||||
mac_address: str | None = None
|
||||
wattage: float | None = Field(default=None, gt=0)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
discovery_source: str = "manual"
|
||||
external_id: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@field_validator("node_type")
|
||||
@classmethod
|
||||
def validate_node_type(cls, v: str) -> str:
|
||||
if v not in VALID_NODE_TYPES:
|
||||
raise ValueError(f"node_type must be one of: {sorted(VALID_NODE_TYPES)}")
|
||||
return v
|
||||
|
||||
@field_validator("ip_address")
|
||||
@classmethod
|
||||
def validate_ip_address(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid IP address: {v!r}")
|
||||
return v
|
||||
|
||||
@field_validator("mac_address")
|
||||
@classmethod
|
||||
def validate_mac_address(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not _MAC_PATTERN.match(v):
|
||||
raise ValueError("MAC address must be in aa:bb:cc:dd:ee:ff format")
|
||||
return v.lower()
|
||||
|
||||
@field_validator("discovery_source")
|
||||
@classmethod
|
||||
def validate_discovery_source(cls, v: str) -> str:
|
||||
if v not in VALID_DISCOVERY_SOURCES:
|
||||
raise ValueError(f"discovery_source must be one of: {sorted(VALID_DISCOVERY_SOURCES)}")
|
||||
return v
|
||||
|
||||
|
||||
class NodeUpdateSchema(NodeCreateSchema):
|
||||
"""Validates PATCH /api/v1/nodes/<id>/ — all fields optional."""
|
||||
|
||||
label: str = Field(default=None, min_length=1, max_length=255) # type: ignore[assignment]
|
||||
node_type: str | None = None # type: ignore[assignment]
|
||||
discovery_source: str = "manual"
|
||||
|
||||
|
||||
class EdgeCreateSchema(BaseModel):
|
||||
"""Validates POST /api/v1/edges/ request body."""
|
||||
|
||||
source: uuid.UUID
|
||||
target: uuid.UUID
|
||||
edge_type: str
|
||||
weight: float = Field(default=1.0, gt=0)
|
||||
label: str = Field(default="", max_length=255)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("edge_type")
|
||||
@classmethod
|
||||
def validate_edge_type(cls, v: str) -> str:
|
||||
if v not in VALID_EDGE_TYPES:
|
||||
raise ValueError(f"edge_type must be one of: {sorted(VALID_EDGE_TYPES)}")
|
||||
return v
|
||||
|
||||
@field_validator("target")
|
||||
@classmethod
|
||||
def source_and_target_differ(cls, v: uuid.UUID, info: Any) -> uuid.UUID:
|
||||
if "source" in info.data and v == info.data["source"]:
|
||||
raise ValueError("source and target must be different nodes")
|
||||
return v
|
||||
|
||||
|
||||
class NetworkCreateSchema(BaseModel):
|
||||
"""Validates POST /api/v1/networks/ request body."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
cidr: str
|
||||
vlan_id: int | None = Field(default=None, ge=1, le=4094)
|
||||
gateway: str | None = None
|
||||
description: str = ""
|
||||
|
||||
@field_validator("cidr")
|
||||
@classmethod
|
||||
def validate_cidr(cls, v: str) -> str:
|
||||
try:
|
||||
ipaddress.ip_network(v, strict=False)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid CIDR notation: {v!r}")
|
||||
return v
|
||||
|
||||
@field_validator("gateway")
|
||||
@classmethod
|
||||
def validate_gateway(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid gateway IP address: {v!r}")
|
||||
return v
|
||||
|
||||
|
||||
class WikiPageUpdateSchema(BaseModel):
|
||||
"""Validates PATCH /api/v1/wiki/<node_id>/ request body."""
|
||||
|
||||
content: str = ""
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import include, path
|
||||
|
||||
from .views import router
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Core API ViewSets — Phase 1 stub.
|
||||
|
||||
The DRF router is registered here; viewsets will be added in Phase 2
|
||||
alongside full CRUD and graph traversal endpoints.
|
||||
"""
|
||||
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
router = DefaultRouter()
|
||||
|
||||
# Phase 2 additions:
|
||||
# from .viewsets import NodeViewSet, EdgeViewSet, NetworkViewSet, WikiPageViewSet
|
||||
# router.register("nodes", NodeViewSet, basename="node")
|
||||
# router.register("edges", EdgeViewSet, basename="edge")
|
||||
# router.register("networks", NetworkViewSet, basename="network")
|
||||
# router.register("wiki", WikiPageViewSet, basename="wiki")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HealthConfig(AppConfig):
|
||||
name = "apps.health"
|
||||
verbose_name = "Health Check"
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import HealthCheckView
|
||||
|
||||
urlpatterns = [
|
||||
path("", HealthCheckView.as_view(), name="health-check"),
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Health check endpoint required by the docker-compose web service healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fs http://localhost:8000/api/health/ || exit 1"]
|
||||
|
||||
Does NOT require authentication — the docker daemon calls this, not users.
|
||||
Returns HTTP 200 when all services are healthy, HTTP 503 on any failure.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import OperationalError, connection
|
||||
from django.http import JsonResponse
|
||||
from django.views import View
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
class HealthCheckView(View):
|
||||
"""
|
||||
Synchronous health check for web, database, and Redis.
|
||||
|
||||
Response body:
|
||||
{
|
||||
"status": "ok" | "degraded",
|
||||
"version": "1.0.0",
|
||||
"services": {
|
||||
"database": "ok" | "error",
|
||||
"redis": "ok" | "error"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def get(self, request) -> JsonResponse: # type: ignore[override]
|
||||
db_status = _check_database()
|
||||
redis_status = _check_redis()
|
||||
|
||||
all_ok = db_status == "ok" and redis_status == "ok"
|
||||
http_status = 200 if all_ok else 503
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "ok" if all_ok else "degraded",
|
||||
"version": VERSION,
|
||||
"services": {
|
||||
"database": db_status,
|
||||
"redis": redis_status,
|
||||
},
|
||||
},
|
||||
status=http_status,
|
||||
)
|
||||
|
||||
|
||||
def _check_database() -> str:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
return "ok"
|
||||
except OperationalError:
|
||||
logger.exception("Database health check failed")
|
||||
return "error"
|
||||
|
||||
|
||||
def _check_redis() -> str:
|
||||
try:
|
||||
cache.set("_health_check", "1", timeout=5)
|
||||
if cache.get("_health_check") != "1":
|
||||
raise RuntimeError("Redis round-trip failed")
|
||||
return "ok"
|
||||
except Exception:
|
||||
logger.exception("Redis health check failed")
|
||||
return "error"
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
ASGI config for LabGraph.
|
||||
|
||||
Kept for future WebSocket support (Phase 3+ real-time status updates).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Celery application for LabGraph.
|
||||
|
||||
Referenced by docker-compose as: celery -A config.celery <subcommand>
|
||||
|
||||
Queues:
|
||||
discovery — Proxmox API polling, nmap network scans
|
||||
heartbeat — Periodic ICMP/TCP liveness checks
|
||||
default — Maintenance, cleanup, housekeeping
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from kombu import Exchange, Queue
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
|
||||
app = Celery("labgraph")
|
||||
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
app.autodiscover_tasks(["tasks"])
|
||||
|
||||
# Explicit queue definitions (mirrors --queues flag in docker-compose worker command)
|
||||
app.conf.task_queues = (
|
||||
Queue("default", Exchange("default"), routing_key="default"),
|
||||
Queue("discovery", Exchange("discovery"), routing_key="discovery"),
|
||||
Queue("heartbeat", Exchange("heartbeat"), routing_key="heartbeat"),
|
||||
)
|
||||
|
||||
app.conf.task_default_queue = "default"
|
||||
app.conf.task_default_exchange = "default"
|
||||
app.conf.task_default_routing_key = "default"
|
||||
|
||||
# Route tasks to queues by module prefix
|
||||
app.conf.task_routes = {
|
||||
"tasks.discovery.*": {"queue": "discovery"},
|
||||
"tasks.heartbeat.*": {"queue": "heartbeat"},
|
||||
"tasks.maintenance.*": {"queue": "default"},
|
||||
}
|
||||
|
||||
app.conf.beat_schedule = {
|
||||
"heartbeat-all-nodes": {
|
||||
"task": "tasks.heartbeat.check_all_nodes",
|
||||
"schedule": 60.0,
|
||||
"queue": "heartbeat",
|
||||
# Drop task if not picked up before the next run fires
|
||||
"options": {"expires": 55},
|
||||
},
|
||||
"discovery-scan-networks": {
|
||||
"task": "tasks.discovery.scan_all_networks",
|
||||
"schedule": crontab(minute="*/15"),
|
||||
"queue": "discovery",
|
||||
},
|
||||
"maintenance-prune-heartbeat-logs": {
|
||||
"task": "tasks.maintenance.prune_old_heartbeat_logs",
|
||||
"schedule": crontab(hour=3, minute=0),
|
||||
"queue": "default",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Base settings shared across all LabGraph environments.
|
||||
|
||||
Never imported directly — always imported via development.py or production.py.
|
||||
All secrets are read from environment variables; missing required vars raise
|
||||
ImproperlyConfigured immediately on startup.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import environ
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent # backend/
|
||||
|
||||
env = environ.Env(
|
||||
DEBUG=(bool, False),
|
||||
ALLOWED_HOSTS=(list, ["localhost", "127.0.0.1"]),
|
||||
)
|
||||
|
||||
environ.Env.read_env(BASE_DIR.parent / ".env")
|
||||
|
||||
# =============================================================================
|
||||
# Security — no defaults for secrets; missing value raises ImproperlyConfigured
|
||||
# =============================================================================
|
||||
SECRET_KEY = env("SECRET_KEY")
|
||||
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
|
||||
|
||||
# =============================================================================
|
||||
# Application definition
|
||||
# =============================================================================
|
||||
DJANGO_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
|
||||
THIRD_PARTY_APPS = [
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"django_celery_beat",
|
||||
"django_celery_results",
|
||||
"drf_spectacular",
|
||||
"django_filters",
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
"apps.core",
|
||||
"apps.health",
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
# =============================================================================
|
||||
# Middleware — CorsMiddleware must be first
|
||||
# =============================================================================
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Database — parsed from DATABASE_URL env var by django-environ
|
||||
# =============================================================================
|
||||
DATABASES = {
|
||||
"default": env.db("DATABASE_URL")
|
||||
}
|
||||
|
||||
# Ensure Django uses UTC-aware datetimes in queries
|
||||
DATABASES["default"]["OPTIONS"] = {"options": "-c timezone=UTC"}
|
||||
|
||||
# =============================================================================
|
||||
# Cache — Redis
|
||||
# =============================================================================
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
||||
"LOCATION": env("REDIS_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Celery — all CELERY_* prefixed settings are picked up by
|
||||
# app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
# =============================================================================
|
||||
CELERY_BROKER_URL = env("CELERY_BROKER_URL")
|
||||
CELERY_RESULT_BACKEND = "django-db"
|
||||
CELERY_CACHE_BACKEND = "default"
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TIMEZONE = "UTC"
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 300
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 270
|
||||
CELERY_WORKER_MAX_TASKS_PER_CHILD = 100
|
||||
|
||||
# =============================================================================
|
||||
# Django REST Framework
|
||||
# =============================================================================
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
],
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_FILTER_BACKENDS": [
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
"rest_framework.filters.SearchFilter",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 50,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# drf-spectacular — OpenAPI schema
|
||||
# =============================================================================
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "LabGraph API",
|
||||
"DESCRIPTION": "HomeLab infrastructure documentation engine — graph-based inventory and monitoring.",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# CORS
|
||||
# =============================================================================
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# =============================================================================
|
||||
# Static & media files
|
||||
# =============================================================================
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "mediafiles"
|
||||
|
||||
# =============================================================================
|
||||
# Internationalisation
|
||||
# =============================================================================
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# =============================================================================
|
||||
# Default primary key
|
||||
# =============================================================================
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# =============================================================================
|
||||
# Password validation
|
||||
# =============================================================================
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Development settings for LabGraph.
|
||||
|
||||
Inherits all base settings and adds:
|
||||
- DEBUG = True
|
||||
- Relaxed CORS (localhost:3000 allowed by default)
|
||||
- Console email backend
|
||||
- Verbose DEBUG-level logging for apps and Celery
|
||||
"""
|
||||
|
||||
from .base import * # noqa: F401, F403
|
||||
from .base import env
|
||||
|
||||
DEBUG = True
|
||||
|
||||
CORS_ALLOWED_ORIGINS = env.list(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
default=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
)
|
||||
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "[{levelname}] {asctime} {name}: {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
},
|
||||
"loggers": {
|
||||
"django": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||
"celery": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
"apps": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
"tasks": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Production settings for LabGraph.
|
||||
|
||||
Inherits all base settings and adds:
|
||||
- DEBUG = False
|
||||
- Strict CORS (origins required from env, no default)
|
||||
- HSTS + secure cookies + SSL redirect
|
||||
- WhiteNoise compressed static files
|
||||
- JSON-formatted structured logging at WARNING level
|
||||
"""
|
||||
|
||||
from .base import * # noqa: F401, F403
|
||||
from .base import env
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Production CORS must be explicitly set — no default
|
||||
CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS")
|
||||
|
||||
# Security hardening
|
||||
SECURE_HSTS_SECONDS = 31_536_000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True)
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
|
||||
# WhiteNoise: compress + fingerprint static files for long-lived caching
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": "logging.Formatter",
|
||||
"fmt": '{"time": "%(asctime)s", "level": "%(levelname)s", "name": "%(name)s", "message": "%(message)s"}',
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "json",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "WARNING",
|
||||
},
|
||||
"loggers": {
|
||||
"django": {"handlers": ["console"], "level": "WARNING", "propagate": False},
|
||||
"celery": {"handlers": ["console"], "level": "WARNING", "propagate": False},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Root URL configuration for LabGraph.
|
||||
|
||||
Routes:
|
||||
/admin/ — Django admin interface
|
||||
/api/health/ — Health check (required by docker-compose healthcheck)
|
||||
/api/v1/ — DRF router (viewsets registered in Phase 2)
|
||||
/api/schema/ — OpenAPI schema (drf-spectacular)
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/health/", include("apps.health.urls")),
|
||||
path("api/v1/", include("apps.core.urls")),
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
WSGI config for LabGraph.
|
||||
|
||||
Entry point used by Gunicorn in docker-compose:
|
||||
gunicorn config.wsgi:application
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
# Web Framework & API
|
||||
django>=5.0,<5.1
|
||||
djangorestframework
|
||||
django-cors-headers
|
||||
django-environ
|
||||
drf-spectacular
|
||||
django-filter
|
||||
|
||||
# Database & Task Queue
|
||||
psycopg[binary]
|
||||
celery[redis]
|
||||
django-celery-results
|
||||
django-celery-beat
|
||||
redis
|
||||
|
||||
# Data Validation & Logic
|
||||
pydantic>=2.0
|
||||
requests
|
||||
python-nmap
|
||||
icmplib
|
||||
Markdown
|
||||
|
||||
# Production Server
|
||||
gunicorn
|
||||
whitenoise
|
||||
|
||||
# Utilities
|
||||
python-dotenv
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
python manage.py migrate --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
exec gunicorn config.wsgi:application \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--workers 4 \
|
||||
--worker-class gthread \
|
||||
--threads 2 \
|
||||
--timeout 120 \
|
||||
--access-logfile - \
|
||||
--error-logfile -
|
||||
@@ -0,0 +1,9 @@
|
||||
-- LabGraph PostgreSQL initialization script.
|
||||
-- Runs once when the postgres volume is first created (via docker-entrypoint-initdb.d).
|
||||
-- Must execute before Django migrations.
|
||||
|
||||
-- uuid_generate_v4() used as default for all UUIDField primary keys
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- GIN index support for fuzzy text search on Node.label
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Discovery task stubs — Phase 1 scaffolding.
|
||||
|
||||
Phase 2 implementations will:
|
||||
scan_all_networks — iterate Network queryset, run nmap per CIDR, upsert Nodes
|
||||
poll_proxmox — call Proxmox API, upsert VM/container Nodes via PROXMOX_URL/TOKEN env vars
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from config.celery import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task(name="tasks.discovery.scan_all_networks", bind=True, max_retries=3)
|
||||
def scan_all_networks(self) -> dict: # type: ignore[type-arg]
|
||||
"""Stub: scan all configured Network CIDRs for live hosts."""
|
||||
logger.info("discovery.scan_all_networks: stub — Phase 2 not yet implemented")
|
||||
return {"status": "stub", "message": "Phase 2 not yet implemented"}
|
||||
|
||||
|
||||
@app.task(name="tasks.discovery.poll_proxmox", bind=True, max_retries=3)
|
||||
def poll_proxmox(self) -> dict: # type: ignore[type-arg]
|
||||
"""Stub: poll Proxmox API for VMs and containers."""
|
||||
logger.info("discovery.poll_proxmox: stub — Phase 2 not yet implemented")
|
||||
return {"status": "stub", "message": "Phase 2 not yet implemented"}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Heartbeat task stubs — Phase 1 scaffolding.
|
||||
|
||||
Phase 2 implementations will:
|
||||
check_all_nodes — query Node.objects.filter(ip_address__isnull=False),
|
||||
dispatch check_single_node subtasks via Celery group()
|
||||
check_single_node — icmplib.ping() for ICMP, socket.connect_ex() for TCP,
|
||||
httpx.get() for HTTP; write HeartbeatLog record
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from config.celery import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task(name="tasks.heartbeat.check_all_nodes", bind=True)
|
||||
def check_all_nodes(self) -> dict: # type: ignore[type-arg]
|
||||
"""Stub: ICMP/TCP liveness check for all nodes that have an IP address."""
|
||||
logger.debug("heartbeat.check_all_nodes: stub — Phase 2 not yet implemented")
|
||||
return {"status": "stub", "checked": 0}
|
||||
|
||||
|
||||
@app.task(name="tasks.heartbeat.check_single_node", bind=True, max_retries=2)
|
||||
def check_single_node(self, node_id: str, check_type: str = "icmp") -> dict: # type: ignore[type-arg]
|
||||
"""Stub: check a single node and write a HeartbeatLog record."""
|
||||
logger.debug("heartbeat.check_single_node: stub for node_id=%s", node_id)
|
||||
return {"status": "stub", "node_id": node_id, "check_type": check_type}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Maintenance tasks — cleanup and housekeeping.
|
||||
|
||||
Scheduled via Celery Beat to run daily at 03:00 UTC.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from config.celery import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task(name="tasks.maintenance.prune_old_heartbeat_logs", bind=True)
|
||||
def prune_old_heartbeat_logs(self, days: int = 30) -> dict: # type: ignore[type-arg]
|
||||
"""
|
||||
Delete HeartbeatLog records older than `days` days.
|
||||
|
||||
Returns the count of deleted rows for monitoring/alerting.
|
||||
Runs as a single bulk DELETE to avoid row-by-row overhead.
|
||||
"""
|
||||
from apps.core.models import HeartbeatLog
|
||||
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
deleted_count, _ = HeartbeatLog.objects.filter(timestamp__lt=cutoff).delete()
|
||||
|
||||
logger.info(
|
||||
"maintenance.prune_old_heartbeat_logs: deleted %d records older than %d days",
|
||||
deleted_count,
|
||||
days,
|
||||
)
|
||||
return {"status": "ok", "deleted": deleted_count, "cutoff_days": days}
|
||||
@@ -0,0 +1,195 @@
|
||||
# =============================================================================
|
||||
# LabGraph — Docker Compose Orchestration
|
||||
# Services: web (Django/Gunicorn), db (PostgreSQL), redis, celery-worker,
|
||||
# celery-beat (scheduler), flower (Celery monitoring)
|
||||
# =============================================================================
|
||||
|
||||
x-django-env: &django-env
|
||||
env_file: .env
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: config.settings.development
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL — Primary Datastore
|
||||
# ---------------------------------------------------------------------------
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: labgraph_db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./backend/scripts/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql:ro
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-labgraph}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-labgraph}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
<<: *healthcheck-defaults
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-labgraph} -d ${POSTGRES_DB:-labgraph}"]
|
||||
networks:
|
||||
- labgraph_net
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis — Celery Broker + Result Backend + Cache Layer
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: labgraph_redis
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
redis-server
|
||||
--requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD must be set}
|
||||
--maxmemory 256mb
|
||||
--maxmemory-policy allkeys-lru
|
||||
--save 60 1
|
||||
--loglevel warning
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
<<: *healthcheck-defaults
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
networks:
|
||||
- labgraph_net
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Django Web — Gunicorn + DRF API
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
container_name: labgraph_web
|
||||
restart: unless-stopped
|
||||
<<: *django-env
|
||||
command: sh /app/scripts/entrypoint.sh
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- static_volume:/app/staticfiles
|
||||
- media_volume:/app/mediafiles
|
||||
- wiki_data:/app/wiki_store
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
<<: *healthcheck-defaults
|
||||
test: ["CMD-SHELL", "curl -fs http://localhost:8000/api/health/ || exit 1"]
|
||||
networks:
|
||||
- labgraph_net
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Celery Worker — Discovery + Heartbeat Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
celery-worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
container_name: labgraph_celery_worker
|
||||
restart: unless-stopped
|
||||
<<: *django-env
|
||||
command: >
|
||||
celery -A config.celery worker
|
||||
--loglevel=info
|
||||
--concurrency=4
|
||||
--queues=discovery,heartbeat,default
|
||||
--hostname=worker@%h
|
||||
--max-tasks-per-child=100
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- wiki_data:/app/wiki_store
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- labgraph_net
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Celery Beat — Periodic Task Scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
celery-beat:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
container_name: labgraph_celery_beat
|
||||
restart: unless-stopped
|
||||
<<: *django-env
|
||||
command: >
|
||||
celery -A config.celery beat
|
||||
--loglevel=info
|
||||
--scheduler django_celery_beat.schedulers:DatabaseScheduler
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- labgraph_net
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flower — Real-time Celery Task Monitor (Dev Only)
|
||||
# ---------------------------------------------------------------------------
|
||||
flower:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
container_name: labgraph_flower
|
||||
restart: unless-stopped
|
||||
<<: *django-env
|
||||
command: >
|
||||
celery -A config.celery flower
|
||||
--port=5555
|
||||
--basic_auth=${FLOWER_USER:-admin}:${FLOWER_PASSWORD:-changeme}
|
||||
--url_prefix=flower
|
||||
ports:
|
||||
- "5555:5555"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- labgraph_net
|
||||
profiles:
|
||||
- dev-tools
|
||||
|
||||
# =============================================================================
|
||||
# Volumes & Networks
|
||||
# =============================================================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
static_volume:
|
||||
driver: local
|
||||
media_volume:
|
||||
driver: local
|
||||
wiki_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
labgraph_net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copy to .env.local and fill in values.
|
||||
|
||||
# NextAuth — required for session signing
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=JeRrknBvonA+p5Xk9vREMaiSRXdbjYqguIXFxSj2KNo=
|
||||
|
||||
# Django backend URL (used by next.config.ts rewrites + api.ts)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copy to .env.local and fill in values.
|
||||
|
||||
# NextAuth — required for session signing
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=change-me-generate-with-openssl-rand-base64-32
|
||||
|
||||
# Django backend URL (used by next.config.ts rewrites + api.ts)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"pages": {
|
||||
"/(dashboard)/dashboard/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(dashboard)/dashboard/page.js"
|
||||
],
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/css/app/layout.css",
|
||||
"static/chunks/app/layout.js"
|
||||
],
|
||||
"/(auth)/login/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/login/page.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
"static/development/_ssgManifest.js"
|
||||
],
|
||||
"rootMainFiles": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"ampFirstPages": []
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"type": "commonjs"}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"/(dashboard)/dashboard/page": "app/(dashboard)/dashboard/page.js",
|
||||
"/api/auth/[...nextauth]/route": "app/api/auth/[...nextauth]/route.js",
|
||||
"/(auth)/login/page": "app/(auth)/login/page.js"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
||||
@@ -0,0 +1,21 @@
|
||||
self.__BUILD_MANIFEST = {
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"ampFirstPages": []
|
||||
};
|
||||
self.__BUILD_MANIFEST.lowPriorityFiles = [
|
||||
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
||||
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
||||
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 3,
|
||||
"middleware": {},
|
||||
"functions": {},
|
||||
"sortedMiddleware": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||
@@ -0,0 +1 @@
|
||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
||||
@@ -0,0 +1 @@
|
||||
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"\n}"
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "9UGEJNrflduV21vrLt4vJtpnHzZwVxNNVIzxibXOc3s="
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,75 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
exports.id = "vendor-chunks/@swc";
|
||||
exports.ids = ["vendor-chunks/@swc"];
|
||||
exports.modules = {
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_class_private_field_loose_base.js":
|
||||
/*!**************************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_class_private_field_loose_base.js ***!
|
||||
\**************************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _class_private_field_loose_base),\n/* harmony export */ _class_private_field_loose_base: () => (/* binding */ _class_private_field_loose_base)\n/* harmony export */ });\nfunction _class_private_field_loose_base(receiver, privateKey) {\n if (!Object.prototype.hasOwnProperty.call(receiver, privateKey)) {\n throw new TypeError(\"attempted to use private field on non-instance\");\n }\n\n return receiver;\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9fY2xhc3NfcHJpdmF0ZV9maWVsZF9sb29zZV9iYXNlLmpzIiwibWFwcGluZ3MiOiI7Ozs7O0FBQU87QUFDUDtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNnRCIsInNvdXJjZXMiOlsid2VicGFjazovL2xhYmdyYXBoLWZyb250ZW5kLy4vbm9kZV9tb2R1bGVzL0Bzd2MvaGVscGVycy9lc20vX2NsYXNzX3ByaXZhdGVfZmllbGRfbG9vc2VfYmFzZS5qcz8zNzNjIl0sInNvdXJjZXNDb250ZW50IjpbImV4cG9ydCBmdW5jdGlvbiBfY2xhc3NfcHJpdmF0ZV9maWVsZF9sb29zZV9iYXNlKHJlY2VpdmVyLCBwcml2YXRlS2V5KSB7XG4gICAgaWYgKCFPYmplY3QucHJvdG90eXBlLmhhc093blByb3BlcnR5LmNhbGwocmVjZWl2ZXIsIHByaXZhdGVLZXkpKSB7XG4gICAgICAgIHRocm93IG5ldyBUeXBlRXJyb3IoXCJhdHRlbXB0ZWQgdG8gdXNlIHByaXZhdGUgZmllbGQgb24gbm9uLWluc3RhbmNlXCIpO1xuICAgIH1cblxuICAgIHJldHVybiByZWNlaXZlcjtcbn1cbmV4cG9ydCB7IF9jbGFzc19wcml2YXRlX2ZpZWxkX2xvb3NlX2Jhc2UgYXMgXyB9O1xuIl0sIm5hbWVzIjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_class_private_field_loose_base.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_class_private_field_loose_key.js":
|
||||
/*!*************************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_class_private_field_loose_key.js ***!
|
||||
\*************************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _class_private_field_loose_key),\n/* harmony export */ _class_private_field_loose_key: () => (/* binding */ _class_private_field_loose_key)\n/* harmony export */ });\nvar id = 0;\n\nfunction _class_private_field_loose_key(name) {\n return \"__private_\" + id++ + \"_\" + name;\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9fY2xhc3NfcHJpdmF0ZV9maWVsZF9sb29zZV9rZXkuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7QUFBQTs7QUFFTztBQUNQO0FBQ0E7QUFDK0MiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9sYWJncmFwaC1mcm9udGVuZC8uL25vZGVfbW9kdWxlcy9Ac3djL2hlbHBlcnMvZXNtL19jbGFzc19wcml2YXRlX2ZpZWxkX2xvb3NlX2tleS5qcz8wMDI4Il0sInNvdXJjZXNDb250ZW50IjpbInZhciBpZCA9IDA7XG5cbmV4cG9ydCBmdW5jdGlvbiBfY2xhc3NfcHJpdmF0ZV9maWVsZF9sb29zZV9rZXkobmFtZSkge1xuICAgIHJldHVybiBcIl9fcHJpdmF0ZV9cIiArIGlkKysgKyBcIl9cIiArIG5hbWU7XG59XG5leHBvcnQgeyBfY2xhc3NfcHJpdmF0ZV9maWVsZF9sb29zZV9rZXkgYXMgXyB9O1xuIl0sIm5hbWVzIjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_class_private_field_loose_key.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_interop_require_default.js":
|
||||
/*!*******************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_interop_require_default.js ***!
|
||||
\*******************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _interop_require_default),\n/* harmony export */ _interop_require_default: () => (/* binding */ _interop_require_default)\n/* harmony export */ });\nfunction _interop_require_default(obj) {\n return obj && obj.__esModule ? obj : { default: obj };\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9faW50ZXJvcF9yZXF1aXJlX2RlZmF1bHQuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7QUFBTztBQUNQLDJDQUEyQztBQUMzQztBQUN5QyIsInNvdXJjZXMiOlsid2VicGFjazovL2xhYmdyYXBoLWZyb250ZW5kLy4vbm9kZV9tb2R1bGVzL0Bzd2MvaGVscGVycy9lc20vX2ludGVyb3BfcmVxdWlyZV9kZWZhdWx0LmpzPzY2NjEiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGZ1bmN0aW9uIF9pbnRlcm9wX3JlcXVpcmVfZGVmYXVsdChvYmopIHtcbiAgICByZXR1cm4gb2JqICYmIG9iai5fX2VzTW9kdWxlID8gb2JqIDogeyBkZWZhdWx0OiBvYmogfTtcbn1cbmV4cG9ydCB7IF9pbnRlcm9wX3JlcXVpcmVfZGVmYXVsdCBhcyBfIH07XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_interop_require_default.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_interop_require_wildcard.js":
|
||||
/*!********************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_interop_require_wildcard.js ***!
|
||||
\********************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _interop_require_wildcard),\n/* harmony export */ _interop_require_wildcard: () => (/* binding */ _interop_require_wildcard)\n/* harmony export */ });\nfunction _getRequireWildcardCache(nodeInterop) {\n if (typeof WeakMap !== \"function\") return null;\n\n var cacheBabelInterop = new WeakMap();\n var cacheNodeInterop = new WeakMap();\n\n return (_getRequireWildcardCache = function(nodeInterop) {\n return nodeInterop ? cacheNodeInterop : cacheBabelInterop;\n })(nodeInterop);\n}\nfunction _interop_require_wildcard(obj, nodeInterop) {\n if (!nodeInterop && obj && obj.__esModule) return obj;\n if (obj === null || typeof obj !== \"object\" && typeof obj !== \"function\") return { default: obj };\n\n var cache = _getRequireWildcardCache(nodeInterop);\n\n if (cache && cache.has(obj)) return cache.get(obj);\n\n var newObj = { __proto__: null };\n var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor;\n\n for (var key in obj) {\n if (key !== \"default\" && Object.prototype.hasOwnProperty.call(obj, key)) {\n var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null;\n if (desc && (desc.get || desc.set)) Object.defineProperty(newObj, key, desc);\n else newObj[key] = obj[key];\n }\n }\n\n newObj.default = obj;\n\n if (cache) cache.set(obj, newObj);\n\n return newObj;\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9faW50ZXJvcF9yZXF1aXJlX3dpbGRjYXJkLmpzIiwibWFwcGluZ3MiOiI7Ozs7O0FBQUE7QUFDQTs7QUFFQTtBQUNBOztBQUVBO0FBQ0E7QUFDQSxLQUFLO0FBQ0w7QUFDTztBQUNQO0FBQ0EsdUZBQXVGOztBQUV2Rjs7QUFFQTs7QUFFQSxtQkFBbUI7QUFDbkI7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7O0FBRUE7O0FBRUE7QUFDQTtBQUMwQyIsInNvdXJjZXMiOlsid2VicGFjazovL2xhYmdyYXBoLWZyb250ZW5kLy4vbm9kZV9tb2R1bGVzL0Bzd2MvaGVscGVycy9lc20vX2ludGVyb3BfcmVxdWlyZV93aWxkY2FyZC5qcz9iYTg5Il0sInNvdXJjZXNDb250ZW50IjpbImZ1bmN0aW9uIF9nZXRSZXF1aXJlV2lsZGNhcmRDYWNoZShub2RlSW50ZXJvcCkge1xuICAgIGlmICh0eXBlb2YgV2Vha01hcCAhPT0gXCJmdW5jdGlvblwiKSByZXR1cm4gbnVsbDtcblxuICAgIHZhciBjYWNoZUJhYmVsSW50ZXJvcCA9IG5ldyBXZWFrTWFwKCk7XG4gICAgdmFyIGNhY2hlTm9kZUludGVyb3AgPSBuZXcgV2Vha01hcCgpO1xuXG4gICAgcmV0dXJuIChfZ2V0UmVxdWlyZVdpbGRjYXJkQ2FjaGUgPSBmdW5jdGlvbihub2RlSW50ZXJvcCkge1xuICAgICAgICByZXR1cm4gbm9kZUludGVyb3AgPyBjYWNoZU5vZGVJbnRlcm9wIDogY2FjaGVCYWJlbEludGVyb3A7XG4gICAgfSkobm9kZUludGVyb3ApO1xufVxuZXhwb3J0IGZ1bmN0aW9uIF9pbnRlcm9wX3JlcXVpcmVfd2lsZGNhcmQob2JqLCBub2RlSW50ZXJvcCkge1xuICAgIGlmICghbm9kZUludGVyb3AgJiYgb2JqICYmIG9iai5fX2VzTW9kdWxlKSByZXR1cm4gb2JqO1xuICAgIGlmIChvYmogPT09IG51bGwgfHwgdHlwZW9mIG9iaiAhPT0gXCJvYmplY3RcIiAmJiB0eXBlb2Ygb2JqICE9PSBcImZ1bmN0aW9uXCIpIHJldHVybiB7IGRlZmF1bHQ6IG9iaiB9O1xuXG4gICAgdmFyIGNhY2hlID0gX2dldFJlcXVpcmVXaWxkY2FyZENhY2hlKG5vZGVJbnRlcm9wKTtcblxuICAgIGlmIChjYWNoZSAmJiBjYWNoZS5oYXMob2JqKSkgcmV0dXJuIGNhY2hlLmdldChvYmopO1xuXG4gICAgdmFyIG5ld09iaiA9IHsgX19wcm90b19fOiBudWxsIH07XG4gICAgdmFyIGhhc1Byb3BlcnR5RGVzY3JpcHRvciA9IE9iamVjdC5kZWZpbmVQcm9wZXJ0eSAmJiBPYmplY3QuZ2V0T3duUHJvcGVydHlEZXNjcmlwdG9yO1xuXG4gICAgZm9yICh2YXIga2V5IGluIG9iaikge1xuICAgICAgICBpZiAoa2V5ICE9PSBcImRlZmF1bHRcIiAmJiBPYmplY3QucHJvdG90eXBlLmhhc093blByb3BlcnR5LmNhbGwob2JqLCBrZXkpKSB7XG4gICAgICAgICAgICB2YXIgZGVzYyA9IGhhc1Byb3BlcnR5RGVzY3JpcHRvciA/IE9iamVjdC5nZXRPd25Qcm9wZXJ0eURlc2NyaXB0b3Iob2JqLCBrZXkpIDogbnVsbDtcbiAgICAgICAgICAgIGlmIChkZXNjICYmIChkZXNjLmdldCB8fCBkZXNjLnNldCkpIE9iamVjdC5kZWZpbmVQcm9wZXJ0eShuZXdPYmosIGtleSwgZGVzYyk7XG4gICAgICAgICAgICBlbHNlIG5ld09ialtrZXldID0gb2JqW2tleV07XG4gICAgICAgIH1cbiAgICB9XG5cbiAgICBuZXdPYmouZGVmYXVsdCA9IG9iajtcblxuICAgIGlmIChjYWNoZSkgY2FjaGUuc2V0KG9iaiwgbmV3T2JqKTtcblxuICAgIHJldHVybiBuZXdPYmo7XG59XG5leHBvcnQgeyBfaW50ZXJvcF9yZXF1aXJlX3dpbGRjYXJkIGFzIF8gfTtcbiJdLCJuYW1lcyI6W10sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_interop_require_wildcard.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_tagged_template_literal_loose.js":
|
||||
/*!*************************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_tagged_template_literal_loose.js ***!
|
||||
\*************************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _tagged_template_literal_loose),\n/* harmony export */ _tagged_template_literal_loose: () => (/* binding */ _tagged_template_literal_loose)\n/* harmony export */ });\nfunction _tagged_template_literal_loose(strings, raw) {\n if (!raw) raw = strings.slice(0);\n\n strings.raw = raw;\n\n return strings;\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9fdGFnZ2VkX3RlbXBsYXRlX2xpdGVyYWxfbG9vc2UuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7QUFBTztBQUNQOztBQUVBOztBQUVBO0FBQ0E7QUFDK0MiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9sYWJncmFwaC1mcm9udGVuZC8uL25vZGVfbW9kdWxlcy9Ac3djL2hlbHBlcnMvZXNtL190YWdnZWRfdGVtcGxhdGVfbGl0ZXJhbF9sb29zZS5qcz9iYWJkIl0sInNvdXJjZXNDb250ZW50IjpbImV4cG9ydCBmdW5jdGlvbiBfdGFnZ2VkX3RlbXBsYXRlX2xpdGVyYWxfbG9vc2Uoc3RyaW5ncywgcmF3KSB7XG4gICAgaWYgKCFyYXcpIHJhdyA9IHN0cmluZ3Muc2xpY2UoMCk7XG5cbiAgICBzdHJpbmdzLnJhdyA9IHJhdztcblxuICAgIHJldHVybiBzdHJpbmdzO1xufVxuZXhwb3J0IHsgX3RhZ2dlZF90ZW1wbGF0ZV9saXRlcmFsX2xvb3NlIGFzIF8gfTtcbiJdLCJuYW1lcyI6W10sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_tagged_template_literal_loose.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(rsc)/./node_modules/@swc/helpers/esm/_interop_require_default.js":
|
||||
/*!*******************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_interop_require_default.js ***!
|
||||
\*******************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _interop_require_default),\n/* harmony export */ _interop_require_default: () => (/* binding */ _interop_require_default)\n/* harmony export */ });\nfunction _interop_require_default(obj) {\n return obj && obj.__esModule ? obj : { default: obj };\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHJzYykvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9faW50ZXJvcF9yZXF1aXJlX2RlZmF1bHQuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7QUFBTztBQUNQLDJDQUEyQztBQUMzQztBQUN5QyIsInNvdXJjZXMiOlsid2VicGFjazovL2xhYmdyYXBoLWZyb250ZW5kLy4vbm9kZV9tb2R1bGVzL0Bzd2MvaGVscGVycy9lc20vX2ludGVyb3BfcmVxdWlyZV9kZWZhdWx0LmpzPzFiMjUiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGZ1bmN0aW9uIF9pbnRlcm9wX3JlcXVpcmVfZGVmYXVsdChvYmopIHtcbiAgICByZXR1cm4gb2JqICYmIG9iai5fX2VzTW9kdWxlID8gb2JqIDogeyBkZWZhdWx0OiBvYmogfTtcbn1cbmV4cG9ydCB7IF9pbnRlcm9wX3JlcXVpcmVfZGVmYXVsdCBhcyBfIH07XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(rsc)/./node_modules/@swc/helpers/esm/_interop_require_default.js\n");
|
||||
|
||||
/***/ })
|
||||
|
||||
};
|
||||
;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
/******/ (() => { // webpackBootstrap
|
||||
/******/ "use strict";
|
||||
/******/ var __webpack_modules__ = ({});
|
||||
/************************************************************************/
|
||||
/******/ // The module cache
|
||||
/******/ var __webpack_module_cache__ = {};
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/ // Check if module is in cache
|
||||
/******/ var cachedModule = __webpack_module_cache__[moduleId];
|
||||
/******/ if (cachedModule !== undefined) {
|
||||
/******/ return cachedModule.exports;
|
||||
/******/ }
|
||||
/******/ // Create a new module (and put it into the cache)
|
||||
/******/ var module = __webpack_module_cache__[moduleId] = {
|
||||
/******/ id: moduleId,
|
||||
/******/ loaded: false,
|
||||
/******/ exports: {}
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Execute the module function
|
||||
/******/ var threw = true;
|
||||
/******/ try {
|
||||
/******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
|
||||
/******/ threw = false;
|
||||
/******/ } finally {
|
||||
/******/ if(threw) delete __webpack_module_cache__[moduleId];
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // Flag the module as loaded
|
||||
/******/ module.loaded = true;
|
||||
/******/
|
||||
/******/ // Return the exports of the module
|
||||
/******/ return module.exports;
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // expose the modules object (__webpack_modules__)
|
||||
/******/ __webpack_require__.m = __webpack_modules__;
|
||||
/******/
|
||||
/************************************************************************/
|
||||
/******/ /* webpack/runtime/compat get default export */
|
||||
/******/ (() => {
|
||||
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||
/******/ __webpack_require__.n = (module) => {
|
||||
/******/ var getter = module && module.__esModule ?
|
||||
/******/ () => (module['default']) :
|
||||
/******/ () => (module);
|
||||
/******/ __webpack_require__.d(getter, { a: getter });
|
||||
/******/ return getter;
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/create fake namespace object */
|
||||
/******/ (() => {
|
||||
/******/ var getProto = Object.getPrototypeOf ? (obj) => (Object.getPrototypeOf(obj)) : (obj) => (obj.__proto__);
|
||||
/******/ var leafPrototypes;
|
||||
/******/ // create a fake namespace object
|
||||
/******/ // mode & 1: value is a module id, require it
|
||||
/******/ // mode & 2: merge all properties of value into the ns
|
||||
/******/ // mode & 4: return value when already ns object
|
||||
/******/ // mode & 16: return value when it's Promise-like
|
||||
/******/ // mode & 8|1: behave like require
|
||||
/******/ __webpack_require__.t = function(value, mode) {
|
||||
/******/ if(mode & 1) value = this(value);
|
||||
/******/ if(mode & 8) return value;
|
||||
/******/ if(typeof value === 'object' && value) {
|
||||
/******/ if((mode & 4) && value.__esModule) return value;
|
||||
/******/ if((mode & 16) && typeof value.then === 'function') return value;
|
||||
/******/ }
|
||||
/******/ var ns = Object.create(null);
|
||||
/******/ __webpack_require__.r(ns);
|
||||
/******/ var def = {};
|
||||
/******/ leafPrototypes = leafPrototypes || [null, getProto({}), getProto([]), getProto(getProto)];
|
||||
/******/ for(var current = mode & 2 && value; typeof current == 'object' && !~leafPrototypes.indexOf(current); current = getProto(current)) {
|
||||
/******/ Object.getOwnPropertyNames(current).forEach((key) => (def[key] = () => (value[key])));
|
||||
/******/ }
|
||||
/******/ def['default'] = () => (value);
|
||||
/******/ __webpack_require__.d(ns, def);
|
||||
/******/ return ns;
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/define property getters */
|
||||
/******/ (() => {
|
||||
/******/ // define getter functions for harmony exports
|
||||
/******/ __webpack_require__.d = (exports, definition) => {
|
||||
/******/ for(var key in definition) {
|
||||
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
|
||||
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/ensure chunk */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.f = {};
|
||||
/******/ // This file contains only the entry chunk.
|
||||
/******/ // The chunk loading function for additional chunks
|
||||
/******/ __webpack_require__.e = (chunkId) => {
|
||||
/******/ return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
|
||||
/******/ __webpack_require__.f[key](chunkId, promises);
|
||||
/******/ return promises;
|
||||
/******/ }, []));
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/get javascript chunk filename */
|
||||
/******/ (() => {
|
||||
/******/ // This function allow to reference async chunks and sibling chunks for the entrypoint
|
||||
/******/ __webpack_require__.u = (chunkId) => {
|
||||
/******/ // return url for filenames based on template
|
||||
/******/ return "" + chunkId + ".js";
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("ab75985b9134bdd7")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/make namespace object */
|
||||
/******/ (() => {
|
||||
/******/ // define __esModule on exports
|
||||
/******/ __webpack_require__.r = (exports) => {
|
||||
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
|
||||
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
/******/ }
|
||||
/******/ Object.defineProperty(exports, '__esModule', { value: true });
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/node module decorator */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.nmd = (module) => {
|
||||
/******/ module.paths = [];
|
||||
/******/ if (!module.children) module.children = [];
|
||||
/******/ return module;
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/startup entrypoint */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.X = (result, chunkIds, fn) => {
|
||||
/******/ // arguments: chunkIds, moduleId are deprecated
|
||||
/******/ var moduleId = chunkIds;
|
||||
/******/ if(!fn) chunkIds = result, fn = () => (__webpack_require__(__webpack_require__.s = moduleId));
|
||||
/******/ chunkIds.map(__webpack_require__.e, __webpack_require__)
|
||||
/******/ var r = fn();
|
||||
/******/ return r === undefined ? result : r;
|
||||
/******/ }
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/require chunk loading */
|
||||
/******/ (() => {
|
||||
/******/ // no baseURI
|
||||
/******/
|
||||
/******/ // object to store loaded chunks
|
||||
/******/ // "1" means "loaded", otherwise not loaded yet
|
||||
/******/ var installedChunks = {
|
||||
/******/ "webpack-runtime": 1
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // no on chunks loaded
|
||||
/******/
|
||||
/******/ var installChunk = (chunk) => {
|
||||
/******/ var moreModules = chunk.modules, chunkIds = chunk.ids, runtime = chunk.runtime;
|
||||
/******/ for(var moduleId in moreModules) {
|
||||
/******/ if(__webpack_require__.o(moreModules, moduleId)) {
|
||||
/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ if(runtime) runtime(__webpack_require__);
|
||||
/******/ for(var i = 0; i < chunkIds.length; i++)
|
||||
/******/ installedChunks[chunkIds[i]] = 1;
|
||||
/******/
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // require() chunk loading for javascript
|
||||
/******/ __webpack_require__.f.require = (chunkId, promises) => {
|
||||
/******/ // "1" is the signal for "already loaded"
|
||||
/******/ if(!installedChunks[chunkId]) {
|
||||
/******/ if("webpack-runtime" != chunkId) {
|
||||
/******/ installChunk(require("./" + __webpack_require__.u(chunkId)));
|
||||
/******/ } else installedChunks[chunkId] = 1;
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ module.exports = __webpack_require__;
|
||||
/******/ __webpack_require__.C = installChunk;
|
||||
/******/
|
||||
/******/ // no HMR
|
||||
/******/
|
||||
/******/ // no HMR manifest
|
||||
/******/ })();
|
||||
/******/
|
||||
/************************************************************************/
|
||||
/******/
|
||||
/******/
|
||||
/******/ })()
|
||||
;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
(self["webpackChunk_N_E"] = self["webpackChunk_N_E"] || []).push([["app/(dashboard)/dashboard/page"],{
|
||||
|
||||
/***/ "(app-pages-browser)/./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?server=false!":
|
||||
/*!*******************************************************************************************************!*\
|
||||
!*** ./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?server=false! ***!
|
||||
\*******************************************************************************************************/
|
||||
/***/ (function(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) {
|
||||
|
||||
|
||||
|
||||
/***/ })
|
||||
|
||||
},
|
||||
/******/ function(__webpack_require__) { // webpackRuntimeModules
|
||||
/******/ var __webpack_exec__ = function(moduleId) { return __webpack_require__(__webpack_require__.s = moduleId); }
|
||||
/******/ __webpack_require__.O(0, ["main-app"], function() { return __webpack_exec__("(app-pages-browser)/./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?server=false!"); });
|
||||
/******/ var __webpack_exports__ = __webpack_require__.O();
|
||||
/******/ _N_E = __webpack_exports__;
|
||||
/******/ }
|
||||
]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
self.__BUILD_MANIFEST = (function(a){return {__rewrites:{afterFiles:[{has:a,source:"\u002Fbackend\u002F:path*",destination:a}],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]}}(void 0));self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||
@@ -0,0 +1 @@
|
||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||
@@ -0,0 +1 @@
|
||||
{"c":[],"r":[],"m":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"c":["app/layout","webpack"],"r":[],"m":[]}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
self["webpackHotUpdate_N_E"]("app/layout",{
|
||||
|
||||
/***/ "(app-pages-browser)/./src/app/globals.css":
|
||||
/*!*****************************!*\
|
||||
!*** ./src/app/globals.css ***!
|
||||
\*****************************/
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"ceca22ab70f2\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL3NyYy9hcHAvZ2xvYmFscy5jc3MiLCJtYXBwaW5ncyI6IjtBQUFBLCtEQUFlLGNBQWM7QUFDN0IsSUFBSSxJQUFVLElBQUksaUJBQWlCIiwic291cmNlcyI6WyJ3ZWJwYWNrOi8vX05fRS8uL3NyYy9hcHAvZ2xvYmFscy5jc3M/NWFhNSJdLCJzb3VyY2VzQ29udGVudCI6WyJleHBvcnQgZGVmYXVsdCBcImNlY2EyMmFiNzBmMlwiXG5pZiAobW9kdWxlLmhvdCkgeyBtb2R1bGUuaG90LmFjY2VwdCgpIH1cbiJdLCJuYW1lcyI6W10sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///(app-pages-browser)/./src/app/globals.css\n"));
|
||||
|
||||
/***/ })
|
||||
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
self["webpackHotUpdate_N_E"]("webpack",{},
|
||||
/******/ function(__webpack_require__) { // webpackRuntimeModules
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.h = function() { return "51d59595f3eabed2"; }
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ }
|
||||
);
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user