Compare commits
89 Commits
style-apps
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f87719e03b | |||
| 1a1b37266c | |||
|
|
ff97d4f2eb | ||
|
|
e56328bd13 | ||
|
|
cfb4281744 | ||
| ffaa2f6c52 | |||
|
|
35d70d06c8 | ||
| 62a27698bf | |||
| bd9e108934 | |||
| ffa4f6606d | |||
| e3e6e7339b | |||
| 505b51e53a | |||
| 4b993c0251 | |||
| 5bbdce4f03 | |||
| 83ef4f6435 | |||
| e64715eafc | |||
| 1654d2b3d2 | |||
| 8cf70319e6 | |||
| 52a1f2b4ae | |||
| 18c35aa398 | |||
| cc1658c355 | |||
| 8b68fa1b7a | |||
| d460d9ed72 | |||
| 44ea62bce5 | |||
| 62511cbc14 | |||
| 3d503aa5b6 | |||
| 2e27471f7f | |||
| f5b32c1b19 | |||
| 985993bef2 | |||
| c0c3c92b55 | |||
| 0945e7c7c2 | |||
| f3475e200a | |||
| 2e0f554f3d | |||
| ed037c5308 | |||
| be3f9aa03b | |||
|
|
9f37cf6b58 | ||
|
|
fa26c24d15 | ||
|
|
4fc5494525 | ||
|
|
924a335682 | ||
|
|
283881b293 | ||
| 6e294363ff | |||
| cb990e8af2 | |||
| 8c1d55b008 | |||
| b8b0ab3dc4 | |||
| b5122bc289 | |||
| 9dfb32b612 | |||
| 20af0dd38d | |||
|
|
2ba058fe10 | ||
|
|
aeb9f4a138 | ||
| 6e55b088e9 | |||
| a26902e414 | |||
| af37eae616 | |||
| 068c7ed48a | |||
| cec81e48c9 | |||
| fdb03f983c | |||
| 727495f7c1 | |||
| 7cadc006d3 | |||
| e0dec56b1d | |||
| 543d02f7a2 | |||
|
|
8a904d23c2 | ||
|
|
4c2e4844a4 | ||
|
|
81daa6667f | ||
| c1f09cc689 | |||
|
|
7790f06519 | ||
| a6b8d502e7 | |||
| 76ca036e2a | |||
| d6d7daf47c | |||
| 86b9facd77 | |||
| 333da4cd80 | |||
| 050ad163fe | |||
| b50de26e30 | |||
| 1f72fe4c57 | |||
| 7f5de9e6b5 | |||
| 09fc4c45bd | |||
| 464d510fc7 | |||
| 67067f5bd2 | |||
| eefb99bc4c | |||
| 5f5e6a951a | |||
| 1d673a6609 | |||
| 8eb5532b7b | |||
| 945b33485e | |||
| f23392b02e | |||
|
|
e5582b9b81 | ||
| 804a95f1b1 | |||
|
|
4c29294d6a | ||
|
|
36478eb507 | ||
|
|
fee9d2cce5 | ||
| 129f1f9152 | |||
| 4d54f86a09 |
134
.woodpecker.yaml
134
.woodpecker.yaml
@ -1,40 +1,118 @@
|
|||||||
when:
|
|
||||||
event:
|
|
||||||
- push
|
|
||||||
- pull_request
|
|
||||||
branch:
|
|
||||||
- master
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: tag
|
build-frontend:
|
||||||
image: alpine
|
name: Build & Push Frontend
|
||||||
commands:
|
image: woodpeckerci/plugin-kaniko
|
||||||
- export TAG_DATE=$(date +%Y%m%d)
|
when:
|
||||||
- export SHORT_SHA=${CI_COMMIT_SHA:0:7}
|
branch: [ master, develop ]
|
||||||
- echo "TAGS=latest,$TAG_DATE-$SHORT_SHA" > .tags.env
|
event: [ push, pull_request, tag ]
|
||||||
|
path:
|
||||||
- name: build-frontend
|
include: [ frontend/** ]
|
||||||
image: woodpeckerci/plugin-docker
|
|
||||||
settings:
|
settings:
|
||||||
repo: harbor.dvirlabs.com/my-apps/navix-frontend
|
registry: harbor.dvirlabs.com
|
||||||
tag_file: .tags.env
|
repo: my-apps/${CI_REPO_NAME}-frontend
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
context: frontend
|
context: frontend
|
||||||
registry: harbor.dvirlabs.com
|
tags:
|
||||||
|
- latest
|
||||||
|
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
|
||||||
username:
|
username:
|
||||||
from_secret: harbor_user
|
from_secret: DOCKER_USERNAME
|
||||||
password:
|
password:
|
||||||
from_secret: harbor_password
|
from_secret: DOCKER_PASSWORD
|
||||||
|
|
||||||
- name: build-backend
|
build-backend:
|
||||||
image: woodpeckerci/plugin-docker
|
name: Build & Push Backend
|
||||||
|
image: woodpeckerci/plugin-kaniko
|
||||||
|
when:
|
||||||
|
branch: [ master, develop ]
|
||||||
|
event: [ push, pull_request, tag ]
|
||||||
|
path:
|
||||||
|
include: [ backend/** ]
|
||||||
settings:
|
settings:
|
||||||
repo: harbor.dvirlabs.com/my-apps/navix-backend
|
registry: harbor.dvirlabs.com
|
||||||
tag_file: .tags.env
|
repo: my-apps/${CI_REPO_NAME}-backend
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
context: backend
|
context: backend
|
||||||
registry: harbor.dvirlabs.com
|
tags:
|
||||||
|
- latest
|
||||||
|
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
|
||||||
username:
|
username:
|
||||||
from_secret: harbor_user
|
from_secret: DOCKER_USERNAME
|
||||||
password:
|
password:
|
||||||
from_secret: harbor_password
|
from_secret: DOCKER_PASSWORD
|
||||||
|
|
||||||
|
update-values-frontend:
|
||||||
|
name: Update frontend tag in values.yaml
|
||||||
|
image: alpine:3.19
|
||||||
|
when:
|
||||||
|
branch: [ master, develop ]
|
||||||
|
event: [ push ]
|
||||||
|
path:
|
||||||
|
include: [ frontend/** ]
|
||||||
|
environment:
|
||||||
|
GIT_USERNAME:
|
||||||
|
from_secret: GIT_USERNAME
|
||||||
|
GIT_TOKEN:
|
||||||
|
from_secret: GIT_TOKEN
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache git yq
|
||||||
|
- git config --global user.name "woodpecker-bot"
|
||||||
|
- git config --global user.email "ci@dvirlabs.com"
|
||||||
|
- git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
|
||||||
|
- cd my-apps
|
||||||
|
- |
|
||||||
|
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||||
|
echo "💡 Setting frontend tag to: $TAG"
|
||||||
|
yq -i ".frontend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||||
|
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||||
|
git commit -m "frontend: update tag to $TAG" || echo "No changes"
|
||||||
|
git push origin HEAD
|
||||||
|
|
||||||
|
update-values-backend:
|
||||||
|
name: Update backend tag in values.yaml
|
||||||
|
image: alpine:3.19
|
||||||
|
when:
|
||||||
|
branch: [ master, develop ]
|
||||||
|
event: [ push ]
|
||||||
|
path:
|
||||||
|
include: [ backend/** ]
|
||||||
|
environment:
|
||||||
|
GIT_USERNAME:
|
||||||
|
from_secret: GIT_USERNAME
|
||||||
|
GIT_TOKEN:
|
||||||
|
from_secret: GIT_TOKEN
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache git yq
|
||||||
|
- git config --global user.name "woodpecker-bot"
|
||||||
|
- git config --global user.email "ci@dvirlabs.com"
|
||||||
|
- |
|
||||||
|
if [ ! -d "my-apps" ]; then
|
||||||
|
git clone "https://${GIT_USERNAME}:${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
|
||||||
|
fi
|
||||||
|
- cd my-apps
|
||||||
|
- |
|
||||||
|
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||||
|
echo "💡 Setting backend tag to: $TAG"
|
||||||
|
yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||||
|
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||||
|
git commit -m "backend: update tag to $TAG" || echo "No changes"
|
||||||
|
git push origin HEAD
|
||||||
|
|
||||||
|
|
||||||
|
trigger-gitops-via-push:
|
||||||
|
name: Trigger apps-gitops via Git push
|
||||||
|
image: alpine/git
|
||||||
|
environment:
|
||||||
|
GIT_USERNAME:
|
||||||
|
from_secret: GIT_USERNAME
|
||||||
|
GIT_TOKEN:
|
||||||
|
from_secret: GIT_TOKEN
|
||||||
|
commands: |
|
||||||
|
git config --global user.name "woodpecker-bot"
|
||||||
|
git config --global user.email "ci@dvirlabs.com"
|
||||||
|
git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/apps-gitops.git"
|
||||||
|
cd apps-gitops
|
||||||
|
echo "# trigger at $(date) by $${CI_REPO_NAME}" >> .trigger
|
||||||
|
git add .trigger
|
||||||
|
git commit -m "ci: trigger apps-gitops build" || echo "no changes"
|
||||||
|
git push origin HEAD
|
||||||
|
|||||||
4
backend/.env
Normal file
4
backend/.env
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
MINIO_ACCESS_KEY=TDJvsBmbkpUXpCw5M7LA
|
||||||
|
MINIO_SECRET_KEY=n9scR7W0MZy6FF0bznV98fSgXpdebIQjqZvEr1Yu
|
||||||
|
MINIO_ENDPOINT=s3.dvirlabs.com
|
||||||
|
MINIO_BUCKET=navix-icons
|
||||||
@ -7,3 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY . .
|
COPY . .
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -1,27 +1,47 @@
|
|||||||
sections:
|
sections:
|
||||||
- apps:
|
- apps:
|
||||||
- description: Dashboards
|
- description: Dashboards
|
||||||
icon: http://192.168.10.118:1111/icons/grafana.svg
|
icon: grafana.svg
|
||||||
name: Grafana
|
name: Grafana
|
||||||
url: https://grafana.dvirlabs.com
|
url: https://grafana.dvirlabs.com
|
||||||
- description: Monitoring
|
- description: Monitoring
|
||||||
icon: http://192.168.10.118:1111/icons/prometheus.svg
|
icon: prometheus.svg
|
||||||
name: Prometheus
|
name: Prometheus
|
||||||
url: https://prometheus.dvirlabs.com
|
url: https://prometheus.dvirlabs.com
|
||||||
|
- description: Kibana logs server
|
||||||
|
icon: kibana.svg
|
||||||
|
name: Kibana
|
||||||
|
url: https://kibana.dvirlabs.com
|
||||||
name: Monitoring
|
name: Monitoring
|
||||||
- apps:
|
- apps:
|
||||||
- description: Git server
|
- description: Git server
|
||||||
icon: http://192.168.10.118:1111/icons/gitea.svg
|
icon: gitea.svg
|
||||||
name: Gitea
|
name: Gitea
|
||||||
url: https://git.dvirlabs.com
|
url: https://git.dvirlabs.com
|
||||||
- description: Container registry
|
- description: Container registry
|
||||||
icon: http://192.168.10.118:1111/icons/harbor.svg
|
icon: harbor.svg
|
||||||
name: Harbor
|
name: Harbor
|
||||||
url: https://harbor.dvirlabs.com
|
url: https://harbor.dvirlabs.com
|
||||||
- description: CI/CD
|
- description: CI/CD
|
||||||
icon: http://192.168.10.118:1111/icons/woodpecker-ci.svg
|
icon: woodpecker-ci.svg
|
||||||
name: Woodpecker
|
name: Woodpecker
|
||||||
url: https://woodpecker.dvirlabs.com
|
url: https://woodpecker.dvirlabs.com
|
||||||
|
- description: CD tool
|
||||||
|
icon: argocd.svg
|
||||||
|
name: ArgoCD
|
||||||
|
url: https://argocd.dvirlabs.com
|
||||||
|
- description: Hashicorp vault
|
||||||
|
icon: vault.svg
|
||||||
|
name: Vault
|
||||||
|
url: https://vault.dvirlabs.com
|
||||||
name: Dev-tools
|
name: Dev-tools
|
||||||
|
- apps: []
|
||||||
|
name: fgbhn
|
||||||
|
- apps: []
|
||||||
|
name: dfgb
|
||||||
|
- apps:
|
||||||
|
- description: opensource s3 app
|
||||||
|
icon: minio.svg
|
||||||
|
name: Minio
|
||||||
|
url: https://minio.dvirlabs.com
|
||||||
|
name: Infra
|
||||||
|
|||||||
109
backend/main.py
109
backend/main.py
@ -1,45 +1,69 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, APIRouter
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import uvicorn
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
import yaml
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from minio import Minio
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
# Allow CORS for all origins
|
router = APIRouter()
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/")
|
load_dotenv()
|
||||||
async def root():
|
|
||||||
return {"message": "Welcome to the FastAPI application!"}
|
|
||||||
|
|
||||||
# Path to apps.yaml (relative to backend/)
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT")
|
||||||
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY")
|
||||||
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY")
|
||||||
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET")
|
||||||
|
|
||||||
|
minio_client = Minio(
|
||||||
|
MINIO_ENDPOINT,
|
||||||
|
access_key=MINIO_ACCESS_KEY,
|
||||||
|
secret_key=MINIO_SECRET_KEY,
|
||||||
|
secure=True
|
||||||
|
)
|
||||||
|
|
||||||
|
BUCKET = MINIO_BUCKET or "navix-icons"
|
||||||
APPS_FILE = Path(__file__).parent / "apps.yaml"
|
APPS_FILE = Path(__file__).parent / "apps.yaml"
|
||||||
|
|
||||||
@app.get("/apps")
|
|
||||||
|
@router.get("/")
|
||||||
|
def root():
|
||||||
|
return {"message": "Welcome to the FastAPI application!"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/apps")
|
||||||
def get_apps():
|
def get_apps():
|
||||||
if not APPS_FILE.exists():
|
if not APPS_FILE.exists():
|
||||||
return {"error": "apps.yaml not found"}
|
return {"error": "apps.yaml not found"}
|
||||||
with open(APPS_FILE, "r") as f:
|
with open(APPS_FILE, "r") as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
class AppData(BaseModel):
|
class AppData(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
icon: str
|
icon: str
|
||||||
description: str
|
description: str
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
class AppEntry(BaseModel):
|
class AppEntry(BaseModel):
|
||||||
section: str
|
section: str
|
||||||
app: AppData
|
app: AppData
|
||||||
|
original_name: str | None = None
|
||||||
|
|
||||||
@app.post("/add_app")
|
|
||||||
|
@router.post("/add_app")
|
||||||
def add_app(entry: AppEntry):
|
def add_app(entry: AppEntry):
|
||||||
if not APPS_FILE.exists():
|
if not APPS_FILE.exists():
|
||||||
current = {"sections": []}
|
current = {"sections": []}
|
||||||
@ -47,7 +71,6 @@ def add_app(entry: AppEntry):
|
|||||||
with open(APPS_FILE, "r") as f:
|
with open(APPS_FILE, "r") as f:
|
||||||
current = yaml.safe_load(f) or {"sections": []}
|
current = yaml.safe_load(f) or {"sections": []}
|
||||||
|
|
||||||
# Find or create section
|
|
||||||
for section in current["sections"]:
|
for section in current["sections"]:
|
||||||
if section["name"] == entry.section:
|
if section["name"] == entry.section:
|
||||||
section["apps"].append(entry.app.dict())
|
section["apps"].append(entry.app.dict())
|
||||||
@ -64,6 +87,68 @@ def add_app(entry: AppEntry):
|
|||||||
return {"status": "added"}
|
return {"status": "added"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/edit_app")
|
||||||
|
def edit_app(entry: AppEntry):
|
||||||
|
if not APPS_FILE.exists():
|
||||||
|
return {"error": "apps.yaml not found"}
|
||||||
|
|
||||||
|
with open(APPS_FILE, "r") as f:
|
||||||
|
current = yaml.safe_load(f) or {"sections": []}
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
for section in current["sections"]:
|
||||||
|
if section["name"] == entry.section:
|
||||||
|
for i, app in enumerate(section["apps"]):
|
||||||
|
if app["name"] == (entry.original_name or entry.app.name):
|
||||||
|
section["apps"][i] = entry.app.dict()
|
||||||
|
updated = True
|
||||||
|
break
|
||||||
|
break
|
||||||
|
|
||||||
|
if not updated:
|
||||||
|
return JSONResponse(status_code=404, content={"error": "App not found to edit"})
|
||||||
|
|
||||||
|
with open(APPS_FILE, "w") as f:
|
||||||
|
yaml.safe_dump(current, f)
|
||||||
|
|
||||||
|
return {"status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/delete_app")
|
||||||
|
def delete_app(entry: AppEntry):
|
||||||
|
if not APPS_FILE.exists():
|
||||||
|
return {"error": "apps.yaml not found"}
|
||||||
|
|
||||||
|
with open(APPS_FILE, "r") as f:
|
||||||
|
current = yaml.safe_load(f) or {"sections": []}
|
||||||
|
|
||||||
|
deleted = False
|
||||||
|
for section in current["sections"]:
|
||||||
|
if section["name"] == entry.section:
|
||||||
|
original_len = len(section["apps"])
|
||||||
|
section["apps"] = [a for a in section["apps"] if a["name"] != entry.app.name]
|
||||||
|
if len(section["apps"]) != original_len:
|
||||||
|
deleted = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
return JSONResponse(status_code=404, content={"error": "App not found to delete"})
|
||||||
|
|
||||||
|
with open(APPS_FILE, "w") as f:
|
||||||
|
yaml.safe_dump(current, f)
|
||||||
|
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/icon/{filename}")
|
||||||
|
def get_public_icon_url(filename: str):
|
||||||
|
url = f"https://{MINIO_ENDPOINT}/{BUCKET}/{filename}"
|
||||||
|
return JSONResponse(content={"url": url})
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ This is the missing part:
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
65
backend/requirements.txt
Normal file
65
backend/requirements.txt
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.4.0
|
||||||
|
argon2-cffi==25.1.0
|
||||||
|
argon2-cffi-bindings==21.2.0
|
||||||
|
boto3==1.34.150
|
||||||
|
botocore==1.34.150
|
||||||
|
certifi==2024.6.2
|
||||||
|
cffi==1.16.0
|
||||||
|
charset-normalizer==3.3.2
|
||||||
|
click==8.1.7
|
||||||
|
colorama==0.4.6
|
||||||
|
cryptography==43.0.0
|
||||||
|
Deprecated==1.2.14
|
||||||
|
dnspython==2.6.1
|
||||||
|
email_validator==2.2.0
|
||||||
|
fastapi==0.111.0
|
||||||
|
fastapi-cli==0.0.4
|
||||||
|
gitdb==4.0.11
|
||||||
|
GitPython==3.1.43
|
||||||
|
h11==0.14.0
|
||||||
|
httpcore==1.0.5
|
||||||
|
httptools==0.6.1
|
||||||
|
httpx==0.27.0
|
||||||
|
idna==3.7
|
||||||
|
Jinja2==3.1.4
|
||||||
|
jmespath==1.0.1
|
||||||
|
markdown-it-py==3.0.0
|
||||||
|
MarkupSafe==2.1.5
|
||||||
|
mdurl==0.1.2
|
||||||
|
minio==7.2.15
|
||||||
|
numpy==2.2.1
|
||||||
|
orjson==3.10.5
|
||||||
|
pandas==2.2.3
|
||||||
|
prometheus_client==0.21.1
|
||||||
|
pycparser==2.22
|
||||||
|
pycryptodome==3.23.0
|
||||||
|
pydantic==2.8.0
|
||||||
|
pydantic_core==2.20.0
|
||||||
|
PyGithub==2.3.0
|
||||||
|
Pygments==2.18.0
|
||||||
|
PyJWT==2.8.0
|
||||||
|
pymongo==4.11.2
|
||||||
|
PyNaCl==1.5.0
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
python-multipart==0.0.9
|
||||||
|
pytz==2024.2
|
||||||
|
PyYAML==6.0.1
|
||||||
|
requests==2.32.3
|
||||||
|
rich==13.7.1
|
||||||
|
s3transfer==0.10.2
|
||||||
|
shellingham==1.5.4
|
||||||
|
six==1.16.0
|
||||||
|
smmap==5.0.1
|
||||||
|
sniffio==1.3.1
|
||||||
|
starlette==0.37.2
|
||||||
|
typer==0.12.3
|
||||||
|
typing_extensions==4.12.2
|
||||||
|
tzdata==2024.2
|
||||||
|
ujson==5.10.0
|
||||||
|
urllib3==2.2.2
|
||||||
|
uvicorn==0.30.1
|
||||||
|
watchfiles==0.22.0
|
||||||
|
websockets==12.0
|
||||||
|
wrapt==1.16.0
|
||||||
1
frontend/.env
Normal file
1
frontend/.env
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:8000
|
||||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -22,3 +22,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Custom files
|
||||||
|
env.js
|
||||||
4
frontend/10-generate-env.sh
Normal file
4
frontend/10-generate-env.sh
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Generate env.js from the template
|
||||||
|
envsubst < /etc/env/env.js.template > /usr/share/nginx/html/env.js
|
||||||
29
frontend/Dockerfile
Normal file
29
frontend/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Stage 1: Build the frontend
|
||||||
|
FROM node:20 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Install dos2unix
|
||||||
|
RUN apk add --no-cache dos2unix
|
||||||
|
|
||||||
|
# Copy built app
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# ✅ Copy env.js.template
|
||||||
|
COPY public/env.js.template /etc/env/env.js.template
|
||||||
|
|
||||||
|
# ✅ Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# ✅ Add env generator script to nginx entrypoint hook
|
||||||
|
COPY 10-generate-env.sh /docker-entrypoint.d/10-generate-env.sh
|
||||||
|
RUN dos2unix /docker-entrypoint.d/10-generate-env.sh && chmod +x /docker-entrypoint.d/10-generate-env.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@ -2,9 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/navix-logo.svg" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500&family=Rajdhani:wght@500&display=swap" rel="stylesheet">
|
||||||
|
<script src="/env.js"></script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React</title>
|
<title>Navix</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
11
frontend/nginx.conf
Normal file
11
frontend/nginx.conf
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
@ -11,7 +11,8 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-icons": "^5.5.0"
|
"react-icons": "^5.5.0",
|
||||||
|
"react-toastify": "^11.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
@ -1488,6 +1489,14 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@ -2440,6 +2449,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-toastify": {
|
||||||
|
"version": "11.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
|
||||||
|
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19",
|
||||||
|
"react-dom": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||||
|
|||||||
@ -13,7 +13,8 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-icons": "^5.5.0"
|
"react-icons": "^5.5.0",
|
||||||
|
"react-toastify": "^11.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
|||||||
6
frontend/public/env.js.template
Normal file
6
frontend/public/env.js.template
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
window.ENV = {
|
||||||
|
API_BASE: "${API_BASE}",
|
||||||
|
MINIO_ENDPOINT: "${MINIO_ENDPOINT}",
|
||||||
|
MINIO_BUCKET: "${MINIO_BUCKET}"
|
||||||
|
};
|
||||||
|
|
||||||
2
frontend/public/navix-logo.svg
Normal file
2
frontend/public/navix-logo.svg
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<svg baseProfile="full" height="256px" version="1.1" width="256px" xmlns="http://www.w3.org/2000/svg" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><filter id="glow"><feGaussianBlur in="SourceGraphic" stdDeviation="5" /></filter></defs><polygon fill="none" filter="url(#glow)" points="128,10 246,128 128,246 10,128" stroke="cyan" stroke-width="10" /></svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
@ -16,7 +16,63 @@ body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-title {
|
/* 🔹 מרכז כותרת */
|
||||||
margin-bottom: 2rem;
|
.title-wrapper {
|
||||||
font-size: 2rem;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navix-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 🔹 תיבת חיפוש בצד שמאל */
|
||||||
|
.search-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
margin-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
background: transparent;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #2e6dc0;
|
||||||
|
color: white;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
width: 240px;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: #7a8aa5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-bottom: 2px solid #4a90e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (שאר סגנונות שלך כמו add-button וכו') */
|
||||||
|
|||||||
@ -1,29 +1,184 @@
|
|||||||
// src/App.jsx
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import SectionGrid from './components/SectionGrid';
|
import SectionGrid from './components/SectionGrid';
|
||||||
import AddAppModal from './components/AddAppModal';
|
import AppModal from './components/AppModal';
|
||||||
|
import ConfirmDialog from './components/ConfirmDialog';
|
||||||
|
import Clock from './components/Clock';
|
||||||
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import { IoIosAdd } from 'react-icons/io';
|
||||||
|
import CustomToast from './components/CustomToast';
|
||||||
|
import {
|
||||||
|
fetchSections,
|
||||||
|
addAppToSection,
|
||||||
|
editAppInSection,
|
||||||
|
deleteAppFromSection,
|
||||||
|
} from './services/api';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [sections, setSections] = useState([]);
|
const [sections, setSections] = useState([]);
|
||||||
|
const [editData, setEditData] = useState(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [confirmData, setConfirmData] = useState(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const fetchSections = () => {
|
const loadSections = () => {
|
||||||
fetch('/apps')
|
fetchSections()
|
||||||
.then(res => res.json())
|
.then(data => {
|
||||||
.then(data => setSections(data.sections || []));
|
const filtered = (data.sections || []).filter(section => (section.apps?.length ?? 0) > 0);
|
||||||
|
setSections(filtered);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to fetch sections:', err));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSections();
|
loadSections();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = (app) => {
|
||||||
|
setConfirmData({
|
||||||
|
message: `Are you sure you want to delete "${app.name}"?`,
|
||||||
|
app,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!confirmData?.app) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAppFromSection({
|
||||||
|
section: confirmData.app.section,
|
||||||
|
app: confirmData.app,
|
||||||
|
});
|
||||||
|
toast(<CustomToast type="delete" message={`App "${confirmData.app.name}" deleted successfully!`} />);
|
||||||
|
loadSections();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete app:', err);
|
||||||
|
toast.error('Failed to delete app');
|
||||||
|
} finally {
|
||||||
|
setConfirmData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSubmit = async (data) => {
|
||||||
|
try {
|
||||||
|
await addAppToSection(data);
|
||||||
|
toast(<CustomToast type="success" message={`App "${data.app.name}" added successfully!`} />);
|
||||||
|
setShowAdd(false);
|
||||||
|
loadSections();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add app:', err);
|
||||||
|
toast.error('Failed to add app');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSubmit = async (data) => {
|
||||||
|
try {
|
||||||
|
await editAppInSection({
|
||||||
|
section: data.section,
|
||||||
|
app: data.app,
|
||||||
|
original_name: data.original_name || data.app.name,
|
||||||
|
});
|
||||||
|
toast(<CustomToast type="edit" message={`App "${data.app.name}" updated successfully!`} />);
|
||||||
|
setEditData(null);
|
||||||
|
loadSections();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update app:', err);
|
||||||
|
toast.error('Failed to update app');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredSections = sections.map(section => ({
|
||||||
|
...section,
|
||||||
|
apps: section.apps.filter(app =>
|
||||||
|
app.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
),
|
||||||
|
})).filter(section => section.apps.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<h1 className="main-title">🔷 Navix</h1>
|
<Clock />
|
||||||
<AddAppModal onAdded={() => window.location.reload()} />
|
|
||||||
{sections.map((section) => (
|
{/* 🔹 לוגו וכותרת במרכז */}
|
||||||
<SectionGrid key={section.name} section={section} />
|
<div className="title-wrapper">
|
||||||
|
<h1 className="main-title">
|
||||||
|
<img src="/navix-logo.svg" alt="Navix logo" className="navix-logo" />
|
||||||
|
Navix
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🔍 שורת חיפוש בצד שמאל */}
|
||||||
|
<div className="search-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search apps..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<AppModal
|
||||||
|
mode="add"
|
||||||
|
onSubmit={handleAddSubmit}
|
||||||
|
onClose={() => setShowAdd(false)}
|
||||||
|
sections={sections}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editData && (
|
||||||
|
<AppModal
|
||||||
|
mode="edit"
|
||||||
|
initialData={editData}
|
||||||
|
onSubmit={handleEditSubmit}
|
||||||
|
onClose={() => setEditData(null)}
|
||||||
|
sections={sections}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmData && (
|
||||||
|
<ConfirmDialog
|
||||||
|
message={confirmData.message}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setConfirmData(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredSections.map((section) => (
|
||||||
|
<SectionGrid
|
||||||
|
key={section.name}
|
||||||
|
section={section}
|
||||||
|
onEdit={(app) =>
|
||||||
|
setEditData({ ...app, section: section.name, original_name: app.name })
|
||||||
|
}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<IoIosAdd className="add-button" onClick={() => setShowAdd(true)} />
|
||||||
|
|
||||||
|
<ToastContainer
|
||||||
|
position="bottom-right"
|
||||||
|
autoClose={3000}
|
||||||
|
hideProgressBar={false}
|
||||||
|
newestOnTop
|
||||||
|
closeOnClick
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
pauseOnHover
|
||||||
|
theme="dark"
|
||||||
|
toastStyle={{
|
||||||
|
backgroundColor: 'black',
|
||||||
|
borderRadius: '12px',
|
||||||
|
minHeight: '110px',
|
||||||
|
padding: '20px',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
bodyClassName="toast-body"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { addAppToSection } from '../services/api';
|
|
||||||
import '../style/AddAppModal.css';
|
|
||||||
import { IoIosAddCircleOutline } from "react-icons/io";
|
|
||||||
|
|
||||||
function AddAppModal({ onAdded }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const [section, setSection] = useState('');
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [icon, setIcon] = useState('');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [url, setUrl] = useState('');
|
|
||||||
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
await addAppToSection({ section, app: { name, icon, description, url } });
|
|
||||||
setOpen(false);
|
|
||||||
setSection('');
|
|
||||||
setName('');
|
|
||||||
setIcon('');
|
|
||||||
setDescription('');
|
|
||||||
setUrl('');
|
|
||||||
if (onAdded) onAdded();
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to add app');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<IoIosAddCircleOutline
|
|
||||||
className="add-button"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="modal-overlay" onClick={() => setOpen(false)}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h2>Add New App</h2>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<input type="text" placeholder="Section" value={section} onChange={e => setSection(e.target.value)} required />
|
|
||||||
<input type="text" placeholder="App name" value={name} onChange={e => setName(e.target.value)} required />
|
|
||||||
<input type="text" placeholder="Icon URL" value={icon} onChange={e => setIcon(e.target.value)} />
|
|
||||||
<input type="text" placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} />
|
|
||||||
<input type="text" placeholder="App URL" value={url} onChange={e => setUrl(e.target.value)} required />
|
|
||||||
<button type="submit">Add</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddAppModal;
|
|
||||||
@ -1,14 +1,43 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import '../style/AppCard.css';
|
import '../style/AppCard.css';
|
||||||
|
import { getIconUrl } from '../services/api';
|
||||||
|
import { FaEdit, FaTrash } from 'react-icons/fa';
|
||||||
|
|
||||||
|
function AppCard({ app, section, onEdit, onDelete }) {
|
||||||
|
const [iconUrl, setIconUrl] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (app.icon) {
|
||||||
|
getIconUrl(app.icon)
|
||||||
|
.then((url) => setIconUrl(url))
|
||||||
|
.catch((err) => console.error(`Failed to load icon for ${app.name}:`, err));
|
||||||
|
}
|
||||||
|
}, [app.icon, app.name]);
|
||||||
|
|
||||||
function AppCard({ app }) {
|
|
||||||
return (
|
return (
|
||||||
|
<div className="app-card-wrapper">
|
||||||
<a href={app.url} className="app-card" target="_blank" rel="noreferrer">
|
<a href={app.url} className="app-card" target="_blank" rel="noreferrer">
|
||||||
|
<div className="card-icons-inside">
|
||||||
|
<FaTrash className="delete-icon" onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof onDelete === 'function') {
|
||||||
|
onDelete({ ...app, section });
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
<FaEdit className="edit-icon" onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof onEdit === 'function') {
|
||||||
|
onEdit({ ...app, section });
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
<div className="app-icon-wrapper">
|
<div className="app-icon-wrapper">
|
||||||
<img src={app.icon} alt={app.name} className="app-icon" />
|
{iconUrl ? <img src={iconUrl} alt={app.name} className="app-icon" /> : <span className="icon-placeholder">⚠️</span>}
|
||||||
</div>
|
</div>
|
||||||
<h3>{app.name}</h3>
|
<h3>{app.name}</h3>
|
||||||
<p>{app.description}</p>
|
<p>{app.description}</p>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import AppCard from './AppCard';
|
import AppCard from './AppCard';
|
||||||
import '../style/AppGrid.css';
|
import '../style/AppGrid.css';
|
||||||
|
|
||||||
function AppGrid({ apps }) {
|
function AppGrid({ apps, section, onEdit, onDelete }) {
|
||||||
return (
|
return (
|
||||||
<div className="app-grid">
|
<div className="app-grid">
|
||||||
{apps.map(app => (
|
{apps.map(app => (
|
||||||
<AppCard key={app.name} app={app} />
|
<AppCard key={app.name} app={app} section={section} onEdit={onEdit} onDelete={onDelete} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
111
frontend/src/components/AppModal.jsx
Normal file
111
frontend/src/components/AppModal.jsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import '../style/AddAppModal.css';
|
||||||
|
|
||||||
|
function AppModal({ mode = 'add', initialData = {}, onSubmit, onClose, sections = [] }) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const [section, setSection] = useState(initialData.section || '');
|
||||||
|
const [customSection, setCustomSection] = useState(false);
|
||||||
|
const [name, setName] = useState(initialData.name || '');
|
||||||
|
const [icon, setIcon] = useState(initialData.icon || '');
|
||||||
|
const [description, setDescription] = useState(initialData.description || '');
|
||||||
|
const [url, setUrl] = useState(initialData.url || '');
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const appData = {
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
section,
|
||||||
|
app: appData,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
payload.original_name = initialData.original_name || initialData.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit(payload);
|
||||||
|
setOpen(false);
|
||||||
|
if (onClose) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={() => { setOpen(false); if (onClose) onClose(); }}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>{mode === 'edit' ? 'Edit App' : 'Add New App'}</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<select
|
||||||
|
value={customSection ? '__new__' : section}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '__new__') {
|
||||||
|
setSection('');
|
||||||
|
setCustomSection(true);
|
||||||
|
} else {
|
||||||
|
setSection(val);
|
||||||
|
setCustomSection(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required={!customSection}
|
||||||
|
disabled={mode === 'edit'}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select a section</option>
|
||||||
|
{sections.map((s) => (
|
||||||
|
<option key={s.name} value={s.name}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__new__">➕ Create new section...</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{customSection && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New section name"
|
||||||
|
value={section}
|
||||||
|
onChange={(e) => setSection(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="App name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Icon name (e.g. grafana.svg) or full URL"
|
||||||
|
value={icon}
|
||||||
|
onChange={(e) => setIcon(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="App URL"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit">{mode === 'edit' ? 'Update' : 'Add'}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppModal;
|
||||||
28
frontend/src/components/Clock.jsx
Normal file
28
frontend/src/components/Clock.jsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import '../style/Clock.css';
|
||||||
|
|
||||||
|
function Clock() {
|
||||||
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const time = now.toLocaleTimeString('en-GB'); // HH:MM:SS
|
||||||
|
const date = now.toLocaleDateString('en-GB', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="clock-container">
|
||||||
|
<span className="clock-time">{time}</span>
|
||||||
|
<span className="clock-date">{date}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Clock;
|
||||||
18
frontend/src/components/ConfirmDialog.jsx
Normal file
18
frontend/src/components/ConfirmDialog.jsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import '../style/AddAppModal.css'; // reuse the modal style
|
||||||
|
|
||||||
|
function ConfirmDialog({ message, onConfirm, onCancel }) {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onCancel}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>Confirm</h2>
|
||||||
|
<p style={{ color: '#ccc', textAlign: 'center', marginBottom: '1.5rem' }}>{message}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
<button style={{ background: 'gray' }} onClick={onCancel}>Cancel</button>
|
||||||
|
<button onClick={onConfirm}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
17
frontend/src/components/CustomToast.jsx
Normal file
17
frontend/src/components/CustomToast.jsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { FaCheckCircle, FaEdit, FaTrash } from 'react-icons/fa';
|
||||||
|
import '../style/CustomToast.css'; // Ensure you have this CSS file for styling
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
success: <FaCheckCircle color="#00ff00" />,
|
||||||
|
edit: <FaEdit color="#ffa500" />,
|
||||||
|
delete: <FaTrash color="#ff4d4d" />
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CustomToast({ type, message }) {
|
||||||
|
return (
|
||||||
|
<div className="custom-toast">
|
||||||
|
<span className="icon">{iconMap[type]}</span>
|
||||||
|
<span className="message">{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,49 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import AppGrid from './AppGrid';
|
import AppGrid from './AppGrid';
|
||||||
|
import { FaChevronDown } from 'react-icons/fa';
|
||||||
import '../style/SectionGrid.css';
|
import '../style/SectionGrid.css';
|
||||||
|
|
||||||
function SectionGrid({ section }) {
|
function SectionGrid({ section, onEdit, onDelete }) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const contentRef = useRef(null);
|
||||||
|
const [height, setHeight] = useState('auto');
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
setCollapsed(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentRef.current) return;
|
||||||
|
|
||||||
|
if (!collapsed) {
|
||||||
|
const scrollHeight = contentRef.current.scrollHeight;
|
||||||
|
setHeight(`${scrollHeight}px`);
|
||||||
|
} else {
|
||||||
|
setHeight('0px');
|
||||||
|
}
|
||||||
|
}, [collapsed, section.apps?.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section">
|
<div className="section">
|
||||||
|
<div className="section-header" onClick={toggle}>
|
||||||
<h2 className="section-title">{section.name}</h2>
|
<h2 className="section-title">{section.name}</h2>
|
||||||
<AppGrid apps={section.apps} />
|
<FaChevronDown className={`toggle-icon ${collapsed ? 'collapsed' : ''}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="section-content"
|
||||||
|
style={{ maxHeight: height }}
|
||||||
|
>
|
||||||
|
<div className="app-grid-wrapper">
|
||||||
|
<AppGrid
|
||||||
|
apps={section.apps}
|
||||||
|
section={section.name}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
|
const API_BASE = window?.ENV?.API_BASE || "";
|
||||||
|
|
||||||
export async function fetchSections() {
|
export async function fetchSections() {
|
||||||
const res = await fetch('/apps');
|
const res = await fetch(`${API_BASE}/apps`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch sections');
|
if (!res.ok) throw new Error('Failed to fetch sections');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addAppToSection({ section, app }) {
|
export async function addAppToSection({ section, app }) {
|
||||||
const res = await fetch('/add_app', {
|
const res = await fetch(`${API_BASE}/add_app`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ section, app })
|
body: JSON.stringify({ section, app })
|
||||||
@ -13,3 +15,32 @@ export async function addAppToSection({ section, app }) {
|
|||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getIconUrl(filename) {
|
||||||
|
const res = await fetch(`${API_BASE}/icon/${filename}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch icon for ${filename}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editAppInSection({ section, app, original_name }) {
|
||||||
|
const res = await fetch(`${API_BASE}/edit_app`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ section, app, original_name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAppFromSection({ section, app }) {
|
||||||
|
const res = await fetch(`${API_BASE}/delete_app`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ section, app })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@ -2,23 +2,25 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 2rem;
|
bottom: 2rem;
|
||||||
right: 2rem;
|
right: 2rem;
|
||||||
background-color: #007bff;
|
width: 64px;
|
||||||
color: white;
|
height: 64px;
|
||||||
font-size: 2rem;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 60px;
|
background: linear-gradient(145deg, #206064, #182b41);
|
||||||
height: 60px;
|
box-shadow: 0 0 12px #00f0ff;
|
||||||
|
color: white;
|
||||||
|
font-size: 2.2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 999;
|
z-index: 1000;
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
transition: background-color 0.3s ease;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-button:hover {
|
.add-button:hover {
|
||||||
background-color: #0056b3;
|
transform: rotate(90deg) scale(1.1);
|
||||||
|
box-shadow: 0 0 20px #00f0ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
@ -34,44 +36,96 @@
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
@keyframes fadeInUp {
|
||||||
background: #1e1e1e;
|
from {
|
||||||
padding: 2rem;
|
opacity: 0;
|
||||||
border-radius: 12px;
|
transform: translateY(40px);
|
||||||
width: 340px;
|
}
|
||||||
height: 420px;
|
to {
|
||||||
box-shadow: 0 0 10px #000;
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Flexbox centering */
|
.modal {
|
||||||
|
background: #111;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 380px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 255, 255, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
animation: fadeInUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal select {
|
||||||
|
width: 95%;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
border: 1px solid #2c2c2c;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: border 0.3s;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal select:focus {
|
||||||
|
border: 1px solid #00f0ff;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 6px #00f0ff40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal option {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-shadow: 0 0 5px #00f0ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal input {
|
.modal input {
|
||||||
width: 90%;
|
width: 95%;
|
||||||
height: 35px;
|
height: 40px;
|
||||||
padding: 0.5rem;
|
padding: 0.6rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 1rem;
|
||||||
background-color: #2c2c2c;
|
background-color: #1f1f1f;
|
||||||
border: none;
|
border: 1px solid #2c2c2c;
|
||||||
color: white;
|
color: #fff;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
|
transition: border 0.3s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input:focus {
|
||||||
|
border: 1px solid #00f0ff;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 6px #00f0ff40;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal button {
|
.modal button {
|
||||||
width: 90%;
|
width: 80%;
|
||||||
padding: 0.5rem;
|
padding: 0.6rem;
|
||||||
background-color: #007bff;
|
background: linear-gradient(to right, #007bff, #00f0ff);
|
||||||
color: white;
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal button:hover {
|
.modal button:hover {
|
||||||
background-color: #0056b3;
|
background: linear-gradient(to right, #00f0ff, #007bff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-button {
|
.add-button {
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
.app-card {
|
.app-card {
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 1.3rem;
|
padding: 0.9rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
width: 150px;
|
width: 170px;
|
||||||
height: 150px;
|
height: 170px;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -36,3 +36,45 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-card h3 {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card p {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icons-inside {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-icon,
|
||||||
|
.delete-icon {
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 4px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-icon:hover,
|
||||||
|
.delete-icon:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|||||||
23
frontend/src/style/Clock.css
Normal file
23
frontend/src/style/Clock.css
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
.clock-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.5rem;
|
||||||
|
right: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
color: #00f0ff;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 255, 0.3);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-time {
|
||||||
|
font-size: 2.1rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-date {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
26
frontend/src/style/CustomToast.css
Normal file
26
frontend/src/style/CustomToast.css
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
.custom-toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-toast .icon {
|
||||||
|
font-size: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-toast .message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Toastify__progress-bar {
|
||||||
|
background: #4e9aff !important;
|
||||||
|
}
|
||||||
@ -1,14 +1,46 @@
|
|||||||
.section {
|
.section {
|
||||||
margin-bottom: 3rem;
|
margin: 0 auto 3rem auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px;
|
max-width: 100%;
|
||||||
|
border-bottom: 1px solid #2e6dc0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 1.5rem;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
color: #ffffff;
|
font-size: 1.8rem;
|
||||||
margin-bottom: 1rem;
|
font-weight: 500;
|
||||||
border-bottom: 1px solid #444;
|
color: white;
|
||||||
padding-bottom: 0.5rem;
|
margin: 0;
|
||||||
text-align: left;
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: #aaa;
|
||||||
|
transition: transform 0.25s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon:hover {
|
||||||
|
color: #2e6dc0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-grid-wrapper {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
14
frontend/vite-config.js-develop
Normal file
14
frontend/vite-config.js-develop
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/apps': 'http://localhost:8000',
|
||||||
|
'/add_app': 'http://localhost:8000',
|
||||||
|
'/icon': 'http://localhost:8000',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -4,10 +4,56 @@ import react from '@vitejs/plugin-react';
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/apps': 'http://localhost:8000',
|
|
||||||
'/add_app': 'http://localhost:8000',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// export default defineConfig({
|
||||||
|
// plugins: [react()],
|
||||||
|
// server: {
|
||||||
|
// proxy: {
|
||||||
|
// '/apps': 'http://localhost:8000',
|
||||||
|
// '/add_app': 'http://localhost:8000',
|
||||||
|
// '/icon': 'http://localhost:8000',
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// import { defineConfig } from 'vite';
|
||||||
|
// import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// export default defineConfig({
|
||||||
|
// plugins: [react()],
|
||||||
|
// });
|
||||||
|
|||||||
4
navix-chart/Chart.yaml
Normal file
4
navix-chart/Chart.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: navix
|
||||||
|
version: 0.1.0
|
||||||
|
description: A DevOps dashboard called Navix
|
||||||
29
navix-chart/templates/backend-deployment.yaml
Normal file
29
navix-chart/templates/backend-deployment.yaml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: navix-backend
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: navix-backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: navix-backend
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: backend
|
||||||
|
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
env:
|
||||||
|
- name: MINIO_ACCESS_KEY
|
||||||
|
value: "{{ .Values.backend.env.MINIO_ACCESS_KEY }}"
|
||||||
|
- name: MINIO_SECRET_KEY
|
||||||
|
value: "{{ .Values.backend.env.MINIO_SECRET_KEY }}"
|
||||||
|
- name: MINIO_ENDPOINT
|
||||||
|
value: "{{ .Values.backend.env.MINIO_ENDPOINT }}"
|
||||||
|
- name: MINIO_BUCKET
|
||||||
|
value: "{{ .Values.backend.env.MINIO_BUCKET }}"
|
||||||
11
navix-chart/templates/backend-service.yaml
Normal file
11
navix-chart/templates/backend-service.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: navix-backend
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.backend.service.type }}
|
||||||
|
selector:
|
||||||
|
app: navix-backend
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.backend.service.port }}
|
||||||
|
targetPort: 8000
|
||||||
27
navix-chart/templates/frontend-deployment.yaml
Normal file
27
navix-chart/templates/frontend-deployment.yaml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: navix-frontend
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: navix-frontend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: navix-frontend
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: frontend
|
||||||
|
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
env:
|
||||||
|
- name: API_BASE
|
||||||
|
value: {{ .Values.frontend.env.API_BASE | quote }}
|
||||||
|
- name: MINIO_ENDPOINT
|
||||||
|
value: {{ .Values.frontend.env.MINIO_ENDPOINT | quote }}
|
||||||
|
- name: MINIO_BUCKET
|
||||||
|
value: {{ .Values.frontend.env.MINIO_BUCKET | quote }}
|
||||||
11
navix-chart/templates/frontend-service.yaml
Normal file
11
navix-chart/templates/frontend-service.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: navix-frontend
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.frontend.service.type }}
|
||||||
|
selector:
|
||||||
|
app: navix-frontend
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.frontend.service.port }}
|
||||||
|
targetPort: 80
|
||||||
57
navix-chart/templates/ingress.yaml
Normal file
57
navix-chart/templates/ingress.yaml
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{{- if .Values.frontend.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: navix-frontend
|
||||||
|
annotations:
|
||||||
|
{{- range $key, $value := .Values.frontend.ingress.annotations }}
|
||||||
|
{{ $key }}: {{ $value | quote }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
ingressClassName: {{ .Values.frontend.ingress.className }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.frontend.ingress.hosts }}
|
||||||
|
- host: {{ .host }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: navix-frontend
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.frontend.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{{- if .Values.backend.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: navix-backend
|
||||||
|
annotations:
|
||||||
|
{{- range $key, $value := .Values.backend.ingress.annotations }}
|
||||||
|
{{ $key }}: {{ $value | quote }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
ingressClassName: {{ .Values.backend.ingress.className }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.backend.ingress.hosts }}
|
||||||
|
- host: {{ .host }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: navix-backend
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.backend.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
48
navix-chart/values.yaml
Normal file
48
navix-chart/values.yaml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
frontend:
|
||||||
|
image:
|
||||||
|
repository: harbor.dvirlabs.com/my-apps/navix-front
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 80
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: traefik
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
hosts:
|
||||||
|
- host: navix.dvirlabs.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
env:
|
||||||
|
API_BASE: "https://navix.dvirlabs.com/api"
|
||||||
|
MINIO_ENDPOINT: "s3.dvirlabs.com"
|
||||||
|
MINIO_BUCKET: "navix-icons"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image:
|
||||||
|
repository: harbor.dvirlabs.com/my-apps/navix-back
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8000
|
||||||
|
env:
|
||||||
|
MINIO_ACCESS_KEY: "your-access-key"
|
||||||
|
MINIO_SECRET_KEY: "your-secret-key"
|
||||||
|
MINIO_ENDPOINT: "s3.dvirlabs.com"
|
||||||
|
MINIO_BUCKET: "navix-icons"
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: traefik
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
hosts:
|
||||||
|
- host: navix.dvirlabs.com
|
||||||
|
paths:
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
Loading…
x
Reference in New Issue
Block a user