Compare commits

...

27 Commits

Author SHA1 Message Date
65cec0a1cb jaeger event add, ip-mgmt edit
All checks were successful
Build And Test / build-and-push (push) Successful in 4m19s
2025-04-14 23:57:11 +09:00
67a3bc05c7 코드 리팩토링
All checks were successful
Build And Test / build-and-push (push) Successful in 4m30s
2025-04-14 19:11:17 +09:00
e4e6dab226 custom span for jaeger
All checks were successful
Build And Test / build-and-push (push) Successful in 4m20s
2025-04-14 15:53:23 +09:00
f8ff4ac4c7 jager endpoint change
All checks were successful
Build And Test / build-and-push (push) Successful in 4m22s
2025-04-14 15:39:49 +09:00
f860373c70 tracing add
All checks were successful
Build And Test / build-and-push (push) Successful in 4m19s
2025-04-14 15:28:57 +09:00
2796994608 create log test
All checks were successful
Build And Test / build-and-push (push) Successful in 4m22s
2025-04-14 13:30:39 +09:00
da71282a05 grafana 분리
All checks were successful
Build And Test / build-and-push (push) Successful in 5m17s
2025-04-11 11:23:34 +09:00
99a3db95a8 README.md update
All checks were successful
Build And Test / build-and-push (push) Successful in 4m21s
2025-04-10 00:23:13 +09:00
ff2e624fc8 ip mgmt view template update 2025-04-10 00:22:05 +09:00
422c1a6341 글작성 미리보기시 이미지 호출로인한 페이지 리로드 이슈 개선, css적용하여 이미지지 div넘어가는 이슈 개선
All checks were successful
Build And Test / build-and-push (push) Successful in 4m22s
2025-04-09 23:53:14 +09:00
06d6010c19 markdown 수정 페이지 호출 방식 변경
All checks were successful
Build And Test / build-and-push (push) Successful in 4m20s
2025-04-09 13:47:35 +09:00
fd3ab674c3 ansible edit 수정
All checks were successful
Build And Test / build-and-push (push) Successful in 4m16s
2025-04-09 13:06:10 +09:00
0b0343d8ce ansible form 순서 수정
All checks were successful
Build And Test / build-and-push (push) Successful in 4m22s
2025-04-09 11:16:54 +09:00
4e66b1b957 update | post list templates, ip mgmt form 분리
All checks were successful
Build And Test / build-and-push (push) Successful in 4m32s
2025-04-01 16:20:14 +09:00
c12d1b527c 개발 환경 변수 호출 우선순위 변경
All checks were successful
Build And Test / build-and-push (push) Successful in 4m36s
2025-03-08 17:16:11 +09:00
fb02b8dc8d telemetry dashboard add
All checks were successful
Build And Test / build-and-push (push) Successful in 4m35s
2025-02-22 03:05:50 +09:00
928758c20d nhnapi 링크 로그인 필요하도록 수정
All checks were successful
Build And Test / build-and-push (push) Successful in 4m10s
2025-02-18 00:58:28 +09:00
a65758a9a8 데브툴 링크 로그인 필요하도록 수정 2025-02-18 00:56:01 +09:00
72b1dd8d37 ip mgmt버그 수정 2025-02-18 00:53:32 +09:00
7d1c508828 Dockerfile : wakeonlan pkg 삭제, sidbar _blank추가
All checks were successful
Build And Test / build-and-push (push) Successful in 4m38s
2025-02-11 22:38:07 +09:00
fc8bfc9db6 Dockerfile : wakeonlan pkg 추가 2025-02-11 22:19:54 +09:00
aacd0e47d2 ip관리대장 db연동필드 변경 && 환경변수 호출로직수정
All checks were successful
Build And Test / build-and-push (push) Successful in 4m5s
2025-01-28 10:01:28 +09:00
1d38fe26bd minio 테스트 인증정보 삭제, 변수 처리 완료
All checks were successful
Build And Test / build-and-push (push) Successful in 4m25s
2025-01-26 01:16:26 +09:00
d8111f6070 minio이미지 업로드 기능 테스트 2025-01-25 22:45:27 +09:00
af57b56e69 공지사항관리 기능 분리 및 개선
All checks were successful
Build And Test / build-and-push (push) Successful in 4m23s
2025-01-25 18:02:49 +09:00
06d1853fb0 url link 저장 및 변경 관리 계정에서 할수 있도록 추가
All checks were successful
Build And Test / build-and-push (push) Successful in 4m8s
2025-01-25 01:30:48 +09:00
d34d4f8def landing페이지 수정, 카테고리 리스트 정리
All checks were successful
Build And Test / build-and-push (push) Successful in 5m13s
2025-01-23 14:19:27 +09:00
88 changed files with 2976 additions and 557 deletions

BIN
README-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@ -1,6 +1,53 @@
# butler_ddochi # butler_ddochi
### dev_0.0.21 butler_ddochi는 개인 테스트 환경 관리를 위한 목적으로 만들어지고 있습니다.
사이드메뉴에 unity 게임 등록. * 클라우드 네이티브 환경을 이용한 서비스 환경 관리
* 쿠버네티스에 배포된 웹 게임 링크 등록. * 쿠버네티스 관리
* 쿠버네티스 클러스터에 배포된 웹 게임 및 istio 설정 필요. * 문의는 icurfer@gmail.com으로 부탁드립니다.
> 문서가 미흡하여 설치 및 구성이 어려울 수 있습니다.
---
## 목차
* [사전 구성요소 선택](#사전-구성-컴포넌트(선택))
* [butler_ddochi설치](#butler_ddochi설치)
### 사전 구성 컴포넌트(필수)
동작을 위한 최소한의 필수 컴포넌트입니다.
* MariaDB
* MinIO
### 사전 구성 컴포넌트(선택)
아래 컴포넌트들이 배포 되어있다면, 로그인 후 Edit Profile을 이용하여 웹콘솔 링크를 입력하여 사용합니다.
![alt text](README-1.png)
* Gitea
* Harbor
* ArgoCD
* CodeServer
* Rancher
* Prometheus
* OpenSearch
* Kiali
---
### butler_ddochi설치
docker 또는 kubernetes를 이용하여 배포 합니다.
> docker pull harbor.icurfer.com/py_prj/butler_ddochi:${version}
필수 환경변수
* DEBUG=0
* SQL_ENGINE='django.db.backends.mysql'
* SQL_HOST='DB_SVR_address'
* SQL_USER='DB_SVR_user'
* SQL_PASSWORD='DB_SVR_user_password'
* SQL_DATABASE='DB_SVR_database'
* SQL_PORT='DB_SVR_portnumber'
* CSRF_TRUSTED_ORIGINS=https://www.example.com,https://butler.example.com,https://butler.example.com:8000
* MM_URL=https://mm.example.com/hooks/${hash}
* (기능 미구현)MINIO_URL=https://minio.example.com/
## 가이드
* https://butler.icurfer.com/notice/

View File

@ -0,0 +1,8 @@
/* 이미지가 div 크기 넘지 않도록 */
#post-content img {
max-width: 100%;
height: auto;
display: block;
/*margin: 0 auto; (선택) 가운데 정렬 */
}

View File

@ -0,0 +1,8 @@
/* 이미지가 div 크기 넘지 않도록 */
#post-content img {
max-width: 100%;
height: auto;
display: block;
/*margin: 0 auto; (선택) 가운데 정렬 */
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.14 on 2025-04-09 13:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ansible_manager', '0003_ansiblejob_delete_ansibletask'),
]
operations = [
migrations.AlterField(
model_name='ansiblejob',
name='inventory_content',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='ansiblejob',
name='playbook_content',
field=models.TextField(blank=True, null=True),
),
]

View File

@ -11,8 +11,8 @@ class AnsibleJob(models.Model):
] ]
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
playbook_content = models.TextField(help_text="Ansible Playbook YAML 내용") playbook_content = models.TextField(blank=True, null=True) # Ansible Playbook YAML 내용
inventory_content = models.TextField(help_text="Ansible Inventory 내용") inventory_content = models.TextField(blank=True, null=True) # Ansible Inventory 내용
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')

View File

@ -6,12 +6,13 @@
<label for="id_name">Name:</label> <label for="id_name">Name:</label>
{{ form.name }} {{ form.name }}
<label for="id_inventory_content">Inventory content:</label>
{{ form.inventory_content }}
<label for="id_playbook_content">Playbook content:</label> <label for="id_playbook_content">Playbook content:</label>
{{ form.playbook_content }} {{ form.playbook_content }}
<label for="id_inventory_content">Inventory content:</label>
{{ form.inventory_content }}
<button type="submit" class="btn btn-primary">Create</button> <button type="submit" class="btn btn-primary">Create</button>
<a href="{% url 'ansible_manager:job_list' %}" class="btn btn-secondary">Cancel</a>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.14 on 2025-01-25 14:39
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0005_rename_content_post_contents'),
]
operations = [
migrations.AlterField(
model_name='post',
name='author',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='blog_posts', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.urls import reverse
from markdown_it import MarkdownIt from markdown_it import MarkdownIt
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@ -10,12 +11,20 @@ class Post(models.Model):
created_at = models.DateTimeField(auto_now_add=True) # 작성일 created_at = models.DateTimeField(auto_now_add=True) # 작성일
updated_at = models.DateTimeField(auto_now=True) # 수정일 updated_at = models.DateTimeField(auto_now=True) # 수정일
tags = TaggableManager() tags = TaggableManager()
author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) author = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.CASCADE,
related_name='blog_posts'
)
def render_markdown(self): def render_markdown(self):
"""마크다운을 HTML로 변환""" """마크다운을 HTML로 변환"""
md = MarkdownIt() md = MarkdownIt()
return md.render(self.contents) return md.render(self.contents)
def get_absolute_url(self):
return reverse('blog:post_detail', args=[str(self.pk)])
def __str__(self): def __str__(self):
return self.title return self.title

View File

@ -1,8 +1,8 @@
{% extends "components/base.html" %} {% extends "components/base.html" %}
{% block title %}Create Notice{% endblock %} {% block title %}Create Post{% endblock %}
{% block main_area %} {% block main_area %}
<h1 class="pt-3">Create New Notice</h1> <h1 class="pt-3">Create New Post</h1>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@ -12,6 +12,14 @@
{{ form.title.label_tag }} {{ form.title.label_tag }}
{{ form.title }} {{ form.title }}
</div> </div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 --> <!-- 마크다운 에디터 -->
<h2>contents</h2> <h2>contents</h2>
@ -22,7 +30,7 @@
<!-- 버튼 --> <!-- 버튼 -->
<div class="d-flex justify-content-end mt-4"> <div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Post</button> <button type="submit" class="btn btn-primary me-2">Create Post</button>
<a href="{% url 'butler:notice_list' %}" class="btn btn-secondary">Cancel</a> <a href="{% url 'blog:post_list' %}" class="btn btn-secondary">Cancel</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -1,39 +0,0 @@
{% extends "components/base.html" %}
{% block title %}Create Post{% endblock %}
{% block main_area %}
<h1>Create New Post</h1>
<div class="row">
<!-- 왼쪽: 마크다운 에디터 -->
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary mt-3">Save</button>
</form>
</div>
<!-- 오른쪽: 미리보기 -->
<div class="col-md-6">
<h2>Preview</h2>
<div id="preview" class="border p-3" style="background: #f8f9fa; min-height: 300px;"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<script>
// 마크다운 파서 초기화
const md = window.markdownit();
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview");
// 실시간 미리보기 업데이트
textarea.addEventListener("input", function () {
const markdownContent = textarea.value; // textarea 내용 가져오기
preview.innerHTML = md.render(markdownContent); // 마크다운 -> HTML 변환
});
</script>
{% endblock %}

View File

@ -22,11 +22,9 @@
</div> </div>
<!-- 마크다운 에디터 --> <!-- 마크다운 에디터 -->
<h2>contents</h2> <h2>Contents</h2>
<textarea id="markdown-editor" name="contents" class="form-control" rows="10"></textarea>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 --> <!-- 버튼 -->
<div class="d-flex justify-content-end mt-4"> <div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Post</button> <button type="submit" class="btn btn-primary me-2">Create Post</button>
@ -34,25 +32,136 @@
</div> </div>
</form> </form>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div> <div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div> </div>
</div> </div>
<!-- 마크다운 파서 및 스크립--> <!-- 마크다운 파서 및 하이라이-->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
// 마크다운 파서 초기화 <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
const md = window.markdownit();
<script>
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return '';
}
});
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor"); const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview"); const preview = document.getElementById("preview");
// 실시간 미리보기 업데이트 let presignedUrlMap = new Map(); // object_name => presigned_url 저장
// 미리보기 업데이트 (textarea 내용은 그대로, 렌더링할 때만 presigned 치환)
function updatePreview() {
const markdownContent = textarea.value;
const lines = markdownContent.split("\n");
const previewLines = [...lines];
for (let i = 0; i < previewLines.length; i++) {
const match = previewLines[i].match(/!\[Image\]\((.+)\)/);
if (match) {
const objectName = match[1];
if (presignedUrlMap.has(objectName)) {
const presignedUrl = presignedUrlMap.get(objectName);
previewLines[i] = `![Image](${presignedUrl})`;
}
}
}
preview.innerHTML = md.render(previewLines.join("\n"));
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
}
// 이미지 붙여넣기 처리
textarea.addEventListener("paste", async function (event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (!file)
return;
const formData = new FormData();
formData.append("image", file);
try {
const response = await fetch("/obs_minio/upload/", {
method: "POST",
body: formData
});
if (response.ok) {
const data = await response.json();
const fullImageUrl = data.url;
const objectName = fullImageUrl
.split("/")
.slice(-2)
.join("/");
// textarea에 object_name 삽입
const markdownImage = `![Image](${objectName})\n`;
const cursorPos = textarea.selectionStart;
const textBefore = textarea
.value
.substring(0, cursorPos);
const textAfter = textarea
.value
.substring(cursorPos);
textarea.value = textBefore + markdownImage + textAfter;
textarea.focus();
// presigned URL 받아서 map에 저장
const presignedResponse = await fetch("/obs_minio/get_presigned_url/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({object_name: objectName})
});
if (presignedResponse.ok) {
const presignedData = await presignedResponse.json();
presignedUrlMap.set(objectName, presignedData.presigned_url);
}
updatePreview();
} else {
alert("Image upload failed. Please try again.");
}
} catch (error) {
console.error("Error uploading image:", error);
alert("An error occurred during image upload.");
}
}
}
});
// 입력할 때는 부드럽게 presigned 적용해서 렌더링
textarea.addEventListener("input", function () { textarea.addEventListener("input", function () {
const markdownContent = textarea.value; // textarea 내용 가져오기 updatePreview();
preview.innerHTML = md.render(markdownContent); // 마크다운 -> HTML 변환 });
document.addEventListener("DOMContentLoaded", function () {
updatePreview();
}); });
</script> </script>
{% endblock %} </div>
{% endblock %}

View File

@ -0,0 +1,80 @@
{% extends "components/base.html" %}
{% block title %}Create Post{% endblock %}
{% block main_area %}
<h1 class="pt-3">Create New Post</h1>
<div class="container">
<div class="row">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Post</button>
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// 마크다운 파서 초기화
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return ''; // 기본 HTML 이스케이프 처리
}
});
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview");
// 실시간 미리보기 업데이트
textarea.addEventListener("input", function () {
const markdownContent = textarea.value; // textarea 내용 가져오기
const renderedContent = md.render(markdownContent); // 마크다운 -> HTML 변환
preview.innerHTML = renderedContent;
// Highlight.js로 코드 블록 강조
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
});
</script>
</div>
{% endblock %}

View File

@ -0,0 +1,128 @@
{% extends "components/base.html" %}
{% block title %}Create Post{% endblock %}
{% block main_area %}
<h1 class="pt-3">Create New Post</h1>
<div class="container">
<div class="row">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<textarea id="markdown-editor" name="contents" class="form-control" rows="10"></textarea>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Post</button>
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// 마크다운 파서 초기화
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return ''; // 기본 HTML 이스케이프 처리
}
});
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview");
// 실시간 미리보기 업데이트
textarea.addEventListener("input", function () {
const markdownContent = textarea.value; // textarea 내용 가져오기
const renderedContent = md.render(markdownContent); // 마크다운 -> HTML 변환
preview.innerHTML = renderedContent;
// Highlight.js로 코드 블록 강조
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
});
// Ctrl+V로 이미지 붙여넣기 처리
textarea.addEventListener("paste", async function (event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (!file)
return;
const formData = new FormData();
formData.append("image", file);
try {
// 이미지 업로드 API 호출
const response = await fetch("/obs_minio/upload/", {
method: "POST",
body: formData
});
if (response.ok) {
const data = await response.json();
const imageUrl = data.url; // 업로드된 이미지 URL
// 마크다운 에디터에 이미지 삽입
const markdownImage = `![Image](${imageUrl})\n`;
textarea.value += markdownImage;
// 미리보기 업데이트
const markdownContent = textarea.value;
const renderedContent = md.render(markdownContent);
preview.innerHTML = renderedContent;
// Highlight.js로 코드 블록 강조
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
} else {
alert("Image upload failed. Please try again.");
}
} catch (error) {
console.error("Error uploading image:", error);
alert("An error occurred during image upload.");
}
}
}
});
</script>
</div>
{% endblock %}

View File

@ -8,9 +8,12 @@
<h5 class="text-muted">{{ post.summary }}</h5> <h5 class="text-muted">{{ post.summary }}</h5>
{% endif %} {% endif %}
<!-- Author --> <!-- Author -->
<div class="text-muted fst-italic mb-2">{{ post.created_at }} <br> <div class="text-muted fst-italic mb-2">{{ post.created_at }}
by. {{ post.author | upper }}</div> <br>
<div> by.
{{ post.author | upper }}</div>
<!-- Rendered Markdown Content -->
<div id="post-content">
{{ post.render_markdown|safe }} {{ post.render_markdown|safe }}
</div> </div>
<hr> <hr>
@ -22,5 +25,82 @@
<br/> <br/>
<br/> <br/>
{% endif %} {% endif %}
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary mt-3">Back to List</a>
{% endblock %} {% if request.user == post.author %}
<div class="d-flex mt-3">
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary me-2">List</a>
<a href="{% url 'blog:update_post' post.pk %}" class="btn btn-warning me-2">Edit</a>
<form method="POST" action="{% url 'blog:delete_post' post.pk %}" class="d-inline" id="delete-form">
{% csrf_token %}
<button type="button" class="btn btn-danger" id="delete-button">Delete</button>
</form>
</div>
{% endif %}
<!-- Highlight.js Styles -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<!-- Highlight.js Script -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// 문서 로드 후 동작
document.addEventListener("DOMContentLoaded", async () => {
// 코드 블럭 하이라이트
document.querySelectorAll("pre code").forEach((block) => {
hljs.highlightElement(block);
});
await updateImagesWithPresignedUrls();
});
// Function to update images with presigned URLs
async function updateImagesWithPresignedUrls() {
const contentDiv = document.getElementById("post-content");
const contentHtml = contentDiv.innerHTML;
const parser = new DOMParser();
const doc = parser.parseFromString(contentHtml, "text/html");
const images = doc.querySelectorAll("img");
for (const img of images) {
const objectName = img.getAttribute("src"); // src에 저장된 object_name
try {
const response = await fetch("/obs_minio/get_presigned_url/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({object_name: objectName})
});
if (response.ok) {
const data = await response.json();
img.setAttribute("src", data.presigned_url); // Presigned URL로 src 업데이트
}
} catch (error) {
console.error("Error fetching presigned URL:", error);
}
}
// 업데이트된 HTML을 다시 삽입
contentDiv.innerHTML = doc.body.innerHTML;
// Reapply syntax highlighting
document
.querySelectorAll("pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
}
// Delete post
document
.getElementById("delete-button")
.addEventListener("click", function () {
const confirmed = confirm("Are you sure you want to delete this post?");
if (confirmed) {
document
.getElementById("delete-form")
.submit();
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "components/base.html" %}
{% block title %}Post{% endblock %}
{% block main_area %}
<!-- Title -->
<h1 class="mt-4">{{ post.title }}</h1>
{% if post.summary %}
<h5 class="text-muted">{{ post.summary }}</h5>
{% endif %}
<!-- Author -->
<div class="text-muted fst-italic mb-2">{{ post.created_at }}
<br>
by.
{{ post.author | upper }}</div>
<div>
{{ post.render_markdown|safe }}
</div>
<hr>
{% if post.tags.exists %}
<i class="fa-solid fa-tags"></i>
{% for tag in post.tags.all %}
<span class="badge bg-primary">{{ tag }}</span>
{% endfor %}
<br/>
<br/>
{% endif %}
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary mt-3">List</a>
{% if request.user == post.author %}
<a href="{% url 'blog:update_post' post.pk %}" class="btn btn-warning">Edit</a>
{% endif %}
{% endblock %}

View File

@ -1,19 +1,39 @@
{% extends "components/base.html" %} {% extends "components/base.html" %}
{% block title %}Post{% endblock %} {% block title %}Blog Posts{% endblock %}
{% block main_area %} {% block main_area %}
<h1 class="pt-3">Blog Posts</h1> <h1 class="pt-3 mb-4 fw-bold">📝 Latest Posts</h1>
{% if request.user.is_authenticated %}
<a href="{% url 'blog:create_post' %}" class="btn btn-primary mb-3">Create New Post</a> {% if request.user.is_authenticated %}
{% endif %} <div class="mb-4">
<ul class="list-group"> <a href="{% url 'blog:create_post' %}" class="btn btn-success">
{% for post in posts %} <i class="bi bi-pencil-square"></i> Write a New Post
<li class="list-group-item"> </a>
<h5>{{ post.title }}</h5> </div>
<p>{{ post.summary }}</p> {% endif %}
<a href="{% url 'blog:post_detail' post.pk %}" class="btn btn-secondary">Read More</a>
</li> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% endfor %} {% for post in posts %}
</ul> <div class="col">
{% endblock %} <div class="card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<h5 class="card-title fw-bold">{{ post.title }}</h5>
<p class="card-text text-muted">{{ post.summary }}</p>
<p class="card-text text-muted">{{ post.contents|truncatechars:50 }}</p>
<div class="mt-auto">
<a href="{{ post.get_absolute_url }}" class="btn btn-outline-primary w-100">
Read More →
</a>
</div>
</div>
<div class="card-footer text-end small text-muted">
{{ post.author }} · {{ post.created_at|date:"Y-m-d" }}
</div>
</div>
</div>
{% empty %}
<p class="text-muted">No posts available yet.</p>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,189 @@
{% extends "components/base.html" %}
{% block title %}post edit{% endblock %}
{% block main_area %}
<h1 class="pt-3">post edit</h1>
<div class="container">
<div class="row mt-3">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Save</button>
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return '';
}
});
const textarea = document.getElementById("markdown-editor"); // ✅ form.contents에 id="markdown-editor" 필수
const preview = document.getElementById("preview");
let presignedUrlMap = new Map(); // object_name => presigned_url 저장
async function generatePresignedUrls() {
const markdownContent = textarea.value;
const lines = markdownContent.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/!\[Image\]\((.+)\)/);
if (match) {
const objectName = match[1];
if (!presignedUrlMap.has(objectName)) {
try {
const response = await fetch("/obs_minio/get_presigned_url/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({object_name: objectName})
});
if (response.ok) {
const data = await response.json();
presignedUrlMap.set(objectName, data.presigned_url);
}
} catch (error) {
console.error("Error fetching presigned URL:", error);
}
}
}
}
}
function updatePreview() {
const markdownContent = textarea.value;
const lines = markdownContent.split("\n");
const previewLines = [...lines];
for (let i = 0; i < previewLines.length; i++) {
const match = previewLines[i].match(/!\[Image\]\((.+)\)/);
if (match) {
const objectName = match[1];
if (presignedUrlMap.has(objectName)) {
const presignedUrl = presignedUrlMap.get(objectName);
previewLines[i] = `![Image](${presignedUrl})`;
}
}
}
preview.innerHTML = md.render(previewLines.join("\n"));
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
}
document.addEventListener("DOMContentLoaded", async function () {
await generatePresignedUrls();
updatePreview();
});
textarea.addEventListener("input", function () {
updatePreview(); // 입력 시에도 presigned 치환 적용
});
textarea.addEventListener("paste", async function (event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (!file)
return;
const formData = new FormData();
formData.append("image", file);
try {
const response = await fetch("/obs_minio/upload/", {
method: "POST",
body: formData
});
if (response.ok) {
const data = await response.json();
const fullImageUrl = data.url;
const objectName = fullImageUrl
.split("/")
.slice(-2)
.join("/");
const markdownImage = `![Image](${objectName})\n`;
// 현재 커서 위치에 이미지 삽입
const cursorPos = textarea.selectionStart;
const textBefore = textarea
.value
.substring(0, cursorPos);
const textAfter = textarea
.value
.substring(cursorPos);
textarea.value = textBefore + markdownImage + textAfter;
textarea.focus();
// 새 이미지니까 presigned 새로 받아야 함
presignedUrlMap.delete(objectName);
await generatePresignedUrls();
updatePreview();
} else {
alert("Image upload failed. Please try again.");
}
} catch (error) {
console.error("Error uploading image:", error);
alert("An error occurred during image upload.");
}
}
}
});
</script>
</div>
{% endblock %}

View File

@ -7,4 +7,6 @@ urlpatterns = [
path('', views.post_list, name='post_list'), # 포스트 리스트 path('', views.post_list, name='post_list'), # 포스트 리스트
path('create/', views.create_post, name='create_post'), # 포스트 작성 path('create/', views.create_post, name='create_post'), # 포스트 작성
path('<int:pk>/', views.post_detail, name='post_detail'), # 포스트 상세 보기 path('<int:pk>/', views.post_detail, name='post_detail'), # 포스트 상세 보기
path('<int:pk>/update/', views.update_post, name='update_post'), # 글 수정 URL
path('<int:pk>/delete/', views.delete_post, name='delete_post'), # 삭제 URL
] ]

View File

@ -3,6 +3,16 @@ from django.contrib.auth.decorators import login_required
from .models import Post from .models import Post
from .forms import PostForm from .forms import PostForm
# 게시글 목록
def post_list(request):
posts = Post.objects.all().order_by('-created_at')
return render(request, 'blog/post_list.html', {'posts': posts})
# 게시글 상세 보기
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
return render(request, 'blog/post_detail.html', {'post': post})
@login_required @login_required
def create_post(request): def create_post(request):
if request.method == 'POST': if request.method == 'POST':
@ -17,10 +27,37 @@ def create_post(request):
form = PostForm() form = PostForm()
return render(request, 'blog/create_post.html', {'form': form}) return render(request, 'blog/create_post.html', {'form': form})
def post_list(request):
posts = Post.objects.all().order_by('-created_at')
return render(request, 'blog/post_list.html', {'posts': posts})
def post_detail(request, pk):
@login_required
def update_post(request, pk):
post = get_object_or_404(Post, pk=pk) post = get_object_or_404(Post, pk=pk)
return render(request, 'blog/post_detail.html', {'post': post})
# 작성자만 수정 가능하도록 권한 검사
if post.author != request.user:
return redirect('blog:post_list')
if request.method == 'POST':
form = PostForm(request.POST, instance=post)
if form.is_valid():
form.save()
return redirect('blog:post_detail', pk=post.pk)
else:
form = PostForm(instance=post)
return render(request, 'blog/update_post.html', {'form': form, 'post': post})
@login_required
def delete_post(request, pk):
post = get_object_or_404(Post, pk=pk)
# 작성자만 삭제 가능
if post.author != request.user:
return redirect('blog:post_list')
# POST 요청일 때 삭제
if request.method == 'POST':
post.delete()
return redirect('blog:post_list')
return render(request, 'blog/confirm_delete.html', {'post': post})

0
board_notice/__init__.py Normal file
View File

4
board_notice/admin.py Normal file
View File

@ -0,0 +1,4 @@
from django.contrib import admin
from .models import BoardNotice
admin.site.register(BoardNotice)

6
board_notice/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BoardNoticeConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'board_notice'

11
board_notice/forms.py Normal file
View File

@ -0,0 +1,11 @@
from django import forms
from .models import BoardNotice
class BoardNoticeForm(forms.ModelForm):
class Meta:
model = BoardNotice
fields = ['title', 'contents']
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control'}),
'contents': forms.Textarea(attrs={'class': 'form-control', 'id': 'markdown-editor'}),
}

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.14 on 2025-01-25 14:39
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BoardNotice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('contents', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='board_notice', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

27
board_notice/models.py Normal file
View File

@ -0,0 +1,27 @@
from django.db import models
from django.conf import settings
from django.urls import reverse
from markdown_it import MarkdownIt
class BoardNotice(models.Model):
title = models.CharField(max_length=200) # 제목
contents = models.TextField() # 본문 (마크다운 저장)
created_at = models.DateTimeField(auto_now_add=True) # 작성일
updated_at = models.DateTimeField(auto_now=True) # 수정일
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.CASCADE,
related_name='board_notice'
)
def render_markdown(self):
"""마크다운을 HTML로 변환"""
md = MarkdownIt()
return md.render(self.contents)
def get_absolute_url(self):
return reverse('board_notice:notice_detail', args=[str(self.pk)])
def __str__(self):
return self.title

View File

@ -0,0 +1,162 @@
{% extends "components/base.html" %}
{% block title %}Create Notice{% endblock %}
{% block main_area %}
<h1 class="pt-3">Create New Notice</h1>
<div class="container">
<div class="row">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Notice</button>
<a href="{% url 'board_notice:notice_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 하이라이트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return '';
}
});
const textarea = document.getElementById("markdown-editor"); // form.contents에서 id="markdown-editor" 꼭 설정돼 있어야 함
const preview = document.getElementById("preview");
let presignedUrlMap = new Map(); // object_name => presigned_url 저장
// 미리보기 렌더링 (fetch 없이 빠르게)
function updatePreview() {
const markdownContent = textarea.value;
const lines = markdownContent.split("\n");
const previewLines = [...lines];
for (let i = 0; i < previewLines.length; i++) {
const match = previewLines[i].match(/!\[Image\]\((.+)\)/);
if (match) {
const objectName = match[1];
if (presignedUrlMap.has(objectName)) {
const presignedUrl = presignedUrlMap.get(objectName);
previewLines[i] = `![Image](${presignedUrl})`;
}
}
}
preview.innerHTML = md.render(previewLines.join("\n"));
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
}
// 이미지 붙여넣기 처리
textarea.addEventListener("paste", async function (event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (!file)
return;
const formData = new FormData();
formData.append("image", file);
try {
const response = await fetch("/obs_minio/upload/", {
method: "POST",
body: formData
});
if (response.ok) {
const data = await response.json();
const fullImageUrl = data.url;
const objectName = fullImageUrl
.split("/")
.slice(-2)
.join("/");
// textarea에 object_name 삽입
const markdownImage = `![Image](${objectName})\n`;
const cursorPos = textarea.selectionStart;
const textBefore = textarea
.value
.substring(0, cursorPos);
const textAfter = textarea
.value
.substring(cursorPos);
textarea.value = textBefore + markdownImage + textAfter;
textarea.focus();
// presigned URL 받아서 map에 저장
const presignedResponse = await fetch("/obs_minio/get_presigned_url/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({object_name: objectName})
});
if (presignedResponse.ok) {
const presignedData = await presignedResponse.json();
presignedUrlMap.set(objectName, presignedData.presigned_url);
}
updatePreview();
} else {
alert("Image upload failed. Please try again.");
}
} catch (error) {
console.error("Error uploading image:", error);
alert("An error occurred during image upload.");
}
}
}
});
// 입력할 때는 부드럽게 미리보기만 갱신
textarea.addEventListener("input", function () {
updatePreview();
});
document.addEventListener("DOMContentLoaded", function () {
updatePreview();
});
</script>
</div>
{% endblock %}

View File

@ -0,0 +1,98 @@
{% extends "components/base.html" %}
{% block title %}Notice{% endblock %}
{% block main_area %}
<!-- Title -->
<h1 class="mt-4">{{ notice.title }}</h1>
<!-- Author -->
<div class="text-muted fst-italic mb-2">
{{ notice.created_at }}<br>
by. {{ notice.author | upper }}
</div>
<!-- Rendered Markdown Content -->
<div id="post-content">
{{ notice.render_markdown|safe }}
</div>
<hr>
{% if request.user == notice.author %}
<div class="d-flex mt-3">
<a href="{% url 'board_notice:notice_list' %}" class="btn btn-secondary me-2">List</a>
<a href="{% url 'board_notice:update_notice' notice.pk %}" class="btn btn-warning me-2">Edit</a>
<form method="POST" action="{% url 'board_notice:delete_notice' notice.pk %}" class="d-inline" id="delete-form">
{% csrf_token %}
<button type="button" class="btn btn-danger" id="delete-button">Delete</button>
</form>
</div>
{% endif %}
<!-- Highlight.js Styles -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<!-- Highlight.js Script -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// 문서 로드 후 동작
document.addEventListener("DOMContentLoaded", async () => {
// 코드 블럭 하이라이트
document.querySelectorAll("pre code").forEach((block) => {
hljs.highlightElement(block);
});
await updateImagesWithPresignedUrls();
});
// 이미지 Presigned URL로 변환
async function updateImagesWithPresignedUrls() {
const contentDiv = document.getElementById("post-content");
const contentHtml = contentDiv.innerHTML;
const parser = new DOMParser();
const doc = parser.parseFromString(contentHtml, "text/html");
const images = doc.querySelectorAll("img");
for (const img of images) {
const objectName = img.getAttribute("src");
try {
const response = await fetch("/obs_minio/get_presigned_url/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ object_name: objectName })
});
if (response.ok) {
const data = await response.json();
img.setAttribute("src", data.presigned_url);
}
} catch (error) {
console.error("Error fetching presigned URL:", error);
}
}
// 변경된 HTML 다시 적용
contentDiv.innerHTML = doc.body.innerHTML;
// 다시 하이라이트 적용
document.querySelectorAll("pre code").forEach((block) => {
hljs.highlightElement(block);
});
}
// Delete 버튼 클릭 이벤트
document
.getElementById("delete-button")
.addEventListener("click", function () {
const confirmed = confirm("Are you sure you want to delete this Notice?");
if (confirmed) {
document
.getElementById("delete-form")
.submit();
}
});
</script>
{% endblock %}

View File

@ -5,27 +5,31 @@
{% block main_area %} {% block main_area %}
<h2 class="fw-bold pt-3 pb-2 d-flex justify-content-between"> <h2 class="fw-bold pt-3 pb-2 d-flex justify-content-between">
공지사항 공지사항
</h2> </h2>
<!-- 공지사항 목록 --> <!-- 공지사항 목록 -->
{% if records %} {% if notices %}
<table class="table table-bordered table-hover"> <table class="table table-bordered table-hover">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th scope="col">글번호</th> <th scope="col">글번호</th>
<th scope="col">제목</th> <th scope="col">제목</th>
<th scope="col">내용</th>
<th scope="col">작성자</th> <th scope="col">작성자</th>
<th scope="col">작성일</th> <th scope="col">작성일</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for record in records %} {% for record in notices %}
<tr> <tr>
<td>{{ record.pk }}</td> <td>{{ record.pk }}</td>
<td> <td>
<a href="{{ record.get_absolute_url }}">{{ record.title }}</a> <a href="{{ record.get_absolute_url }}">{{ record.title }}</a>
</td> </td>
<td>
<a href="{{ record.get_absolute_url }}">{{ record.contents|truncatechars:50 }}</a>
</td>
<td>{{ record.author }}</td> <td>{{ record.author }}</td>
<td>{{ record.created_at|date:"Y-m-d H:i" }}</td> <td>{{ record.created_at|date:"Y-m-d H:i" }}</td>
</tr> </tr>
@ -36,9 +40,9 @@
<p class="text-muted">현재 공지사항이 없습니다.</p> <p class="text-muted">현재 공지사항이 없습니다.</p>
{% endif %} {% endif %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<!-- 버튼 컨테이너 --> <!-- 버튼 컨테이너 -->
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<a href="{% url 'butler:create_notice' %}" class="btn btn-primary">Create Notice</a> <a href="{% url 'board_notice:create_notice' %}" class="btn btn-primary">Create Notice</a>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,193 @@
{% extends "components/base.html" %}
{% block title %}notice edit{% endblock %}
{% block main_area %}
<h1 class="pt-3">notice edit</h1>
<div class="container">
<div class="row mt-3">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Save</button>
<a href="{% url 'board_notice:notice_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return '';
}
});
const textarea = document.getElementById("markdown-editor"); // ✅ 반드시 id="markdown-editor" 되어야 함
const preview = document.getElementById("preview");
let presignedUrlMap = new Map(); // object_name => presigned_url 매핑
// textarea 내용에서 presigned URL을 받아오기
async function generatePresignedUrls() {
const markdownContent = textarea.value;
const lines = markdownContent.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/!\[Image\]\((.+)\)/);
if (match) {
const objectName = match[1];
if (!presignedUrlMap.has(objectName)) { // 이미 있으면 다시 안 불러옴
try {
const response = await fetch("/obs_minio/get_presigned_url/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({object_name: objectName})
});
if (response.ok) {
const data = await response.json();
presignedUrlMap.set(objectName, data.presigned_url);
}
} catch (error) {
console.error("Error fetching presigned URL:", error);
}
}
}
}
}
// textarea의 내용을 presigned URL 적용해서 렌더링
function updatePreview() {
const markdownContent = textarea.value;
const lines = markdownContent.split("\n");
const previewLines = [...lines];
for (let i = 0; i < previewLines.length; i++) {
const match = previewLines[i].match(/!\[Image\]\((.+)\)/);
if (match) {
const objectName = match[1];
if (presignedUrlMap.has(objectName)) {
const presignedUrl = presignedUrlMap.get(objectName);
previewLines[i] = `![Image](${presignedUrl})`;
}
}
}
preview.innerHTML = md.render(previewLines.join("\n"));
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
}
// 페이지 처음 열릴 때
document.addEventListener("DOMContentLoaded", async function () {
await generatePresignedUrls(); // presigned URL 가져오기
updatePreview(); // 그리고 미리보기 렌더링
});
// 키 입력할 때
textarea.addEventListener("input", function () {
updatePreview(); // 항상 최신 입력을 presigned 매핑으로 렌더링
});
// 이미지 붙여넣기
textarea.addEventListener("paste", async function (event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (!file)
return;
const formData = new FormData();
formData.append("image", file);
try {
const response = await fetch("/obs_minio/upload/", {
method: "POST",
body: formData
});
if (response.ok) {
const data = await response.json();
const fullImageUrl = data.url;
const objectName = fullImageUrl
.split("/")
.slice(-2)
.join("/");
const markdownImage = `![Image](${objectName})\n`;
// 현재 커서 위치에 붙여넣기
const cursorPos = textarea.selectionStart;
const textBefore = textarea
.value
.substring(0, cursorPos);
const textAfter = textarea
.value
.substring(cursorPos);
textarea.value = textBefore + markdownImage + textAfter;
textarea.focus();
// 새 이미지니까 presigned map 갱신 필요
presignedUrlMap.delete(objectName);
await generatePresignedUrls(); // 새로 presigned URL 받아오기
updatePreview(); // 그리고 렌더링
} else {
alert("Image upload failed. Please try again.");
}
} catch (error) {
console.error("Error uploading image:", error);
alert("An error occurred during image upload.");
}
}
}
});
</script>
</div>
{% endblock %}

3
board_notice/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

12
board_notice/urls.py Normal file
View File

@ -0,0 +1,12 @@
from django.urls import path
from . import views
app_name = 'board_notice'
urlpatterns = [
path('', views.notice_list, name='notice_list'), # 포스트 리스트
path('create/', views.create_notice, name='create_notice'), # 포스트 작성
path('<int:pk>/', views.notice_detail, name='notice_detail'), # 포스트 상세 보기
path('<int:pk>/update/', views.update_notice, name='update_notice'), # 글 수정 URL
path('<int:pk>/delete/', views.delete_notice, name='delete_notice'), # 삭제 URL
]

62
board_notice/views.py Normal file
View File

@ -0,0 +1,62 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from .models import BoardNotice
from .forms import BoardNoticeForm
# 게시글 목록
def notice_list(request):
notices = BoardNotice.objects.all().order_by('-created_at')
return render(request, 'board_notice/notice_list.html', {'notices': notices})
# 게시글 상세 보기
def notice_detail(request, pk):
notice = get_object_or_404(BoardNotice, pk=pk)
return render(request, 'board_notice/notice_detail.html', {'notice': notice})
# 공지사항 게시글 등록
@login_required
def create_notice(request):
if request.method == 'POST':
form = BoardNoticeForm(request.POST)
if form.is_valid():
notice = form.save(commit=False)
notice.author = request.user # 작성자 정보 추가
notice.save()
return redirect('board_notice:notice_list')
else:
form = BoardNoticeForm()
return render(request, 'board_notice/create_notice.html', {'form': form})
# 공지사항 게시글 수정
@login_required
def update_notice(request, pk):
notice = get_object_or_404(BoardNotice, pk=pk)
# 작성자만 수정 가능하도록 권한 검사
if notice.author != request.user:
return redirect('board_notice:notice_list')
if request.method == 'POST':
form = BoardNoticeForm(request.POST, instance=notice)
if form.is_valid():
form.save()
return redirect('board_notice:notice_detail', pk=notice.pk)
else:
form = BoardNoticeForm(instance=notice)
return render(request, 'board_notice/update_notice.html', {'form': form, 'notice': notice})
# 공지사항 게시글 삭제
@login_required
def delete_notice(request, pk):
notice = get_object_or_404(BoardNotice, pk=pk)
# 작성자만 삭제 가능하도록 권한 검사
if notice.author != request.user:
return redirect('board_notice:notice_list')
if request.method == 'POST':
notice.delete()
return redirect('board_notice:notice_list')
return render(request, 'board_notice/confirm_delete.html', {'notice': notice})

View File

@ -1,5 +1,4 @@
from django.contrib import admin from django.contrib import admin
from .models import IPManagementRecord, NoticeBoard from .models import IPManagementRecord
admin.site.register(IPManagementRecord) admin.site.register(IPManagementRecord)
admin.site.register(NoticeBoard)

View File

@ -1,11 +1 @@
from django import forms from django import forms
from .models import NoticeBoard
class PostForm(forms.ModelForm):
class Meta:
model = NoticeBoard
fields = ['title', 'contents']
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control'}),
'contents': forms.Textarea(attrs={'class': 'form-control', 'id': 'markdown-editor'}),
}

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.14 on 2025-01-25 11:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('butler', '0002_noticeboard'),
]
operations = [
migrations.RenameField(
model_name='ipmanagementrecord',
old_name='desc',
new_name='contents',
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.14 on 2025-01-25 19:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('butler', '0003_rename_desc_ipmanagementrecord_contents'),
]
operations = [
migrations.DeleteModel(
name='NoticeBoard',
),
]

View File

@ -7,7 +7,7 @@ class IPManagementRecord(models.Model):
network_nm = models.CharField(max_length=100) # NETWORK_NM network_nm = models.CharField(max_length=100) # NETWORK_NM
ip_addrs = models.CharField(max_length=50) # IP_ADDRS ip_addrs = models.CharField(max_length=50) # IP_ADDRS
svr_nm = models.CharField(max_length=100) # SVR_NM svr_nm = models.CharField(max_length=100) # SVR_NM
desc = models.TextField(blank=True, null=True) # DESC contents = models.TextField(blank=True, null=True) # desc -> contents
remark = models.TextField(blank=True, null=True) # REMARK remark = models.TextField(blank=True, null=True) # REMARK
created_at = models.DateTimeField(auto_now_add=True) # 생성 시간 자동 기록 created_at = models.DateTimeField(auto_now_add=True) # 생성 시간 자동 기록
updated_at = models.DateTimeField(auto_now=True) # 수정 시간 자동 기록 updated_at = models.DateTimeField(auto_now=True) # 수정 시간 자동 기록
@ -15,21 +15,4 @@ class IPManagementRecord(models.Model):
author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)
def __str__(self): def __str__(self):
return f'{self.network_nm} {self.ip_addrs} {self.svr_nm} {self.desc}' return f'{self.network_nm} {self.ip_addrs} {self.svr_nm} {self.contents}'
class NoticeBoard(models.Model):
title = models.CharField(max_length=100)
contents = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True) # 생성 시간 자동 기록
updated_at = models.DateTimeField(auto_now=True) # 수정 시간 자동 기록
author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)
def __str__(self):
return f'{self.title} {self.author} {self.created_at}'
def get_absolute_url(self):
return reverse('butler:notice_detail', args=[str(self.pk)])
# <a href="{% url 'butler:notice_detail' record.pk %}">{{ record.title }}</a>

View File

@ -0,0 +1,13 @@
{% extends "components/base.html" %}
{% block title %}Landing Page{% endblock %}
{% block main_area %}
<article class="pt-3">
<h2 class="fw-bold pb-2">Welcome!</h2>
<h3>IT Infra 및 DevOps 자원관리 도구</h3>
* IP리스트 관리기능 제공 <br>
* Public NHN Cloud API 기능 일부 제공
<p>계속 기능 구현 중 입니다.</p>
</article>
{% endblock %}

View File

@ -0,0 +1,200 @@
{% extends "components/base.html" %}
{% block title %}IP Management{% endblock %}
{% block main_area %}
<h2 class="fw-bold pt-3 pb-2">IP 관리 대장</h2>
{% if not request.user.is_authenticated %}
<p class="text-danger">비로그인 익명사용자로 접근 중입니다.
<br>로그인시 로그인 사용자가 등록한 데이터만 조회됩니다.
</p>
{% endif %}
<!-- 검색 폼 -->
<div class="row">
<div class="col-4">
</div>
<div class="col-4">
</div>
<div class="col-4">
<form method="get" class="mb-3">
<div class="input-group">
<input type="text" name="var_search" class="form-control" placeholder="Search by Author or Network Name..."
value="{{ var_search }}">
<button type="submit" class="btn btn-outline-primary">Search</button>
</div>
</form>
</div>
</div>
<!-- IP 레코드 목록 -->
<form id="recordForm" method="post" action="">
{% csrf_token %}
<table class="table table-striped table-hover table-bordered">
<thead class="table-dark">
<tr>
<th scope="col">Select</th>
<th scope="col">Network Name</th>
<th scope="col">Location</th>
<th scope="col">Server Name</th>
<th scope="col">IP Address</th>
<th scope="col">Remark</th>
<th scope="col">Author</th>
<th scope="col">Updated At</th>
</tr>
</thead>
<tbody>
{% for record in records %}
<tr data-record-id="{{ record.id }}">
<td class="text-center">
<input type="checkbox" name="selected_records" value="{{ record.id }}"
class="form-check-input record-checkbox">
</td>
<td>{{ record.network_nm }}</td>
<td>{{ record.contents }}</td>
<td>{{ record.svr_nm }}</td>
<td>{{ record.ip_addrs }}</td>
<td>{{ record.remark }}</td>
<td>{{ record.author }}</td>
<td>{{ record.updated_at|date:"Y-m-d H:i" }}</td>
</tr>
<!-- 수정 모달 -->
<div class="modal fade" id="editDataModal-{{ record.id }}" tabindex="-1"
aria-labelledby="editDataModalLabel-{{ record.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-warning text-dark">
<h5 class="modal-title" id="editDataModalLabel-{{ record.id }}">Edit IP Record</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- 데이터 수정 폼 -->
<form id="editDataForm-{{ record.id }}" method="post" action="{% url 'butler:ip_mgmt_edit' record.id %}">
{% csrf_token %}
<div class="mb-3">
<label for="networkNm-{{ record.id }}" class="form-label">Network Name</label>
<input type="text" class="form-control" id="networkNm-{{ record.id }}" name="network_nm"
value="{{ record.network_nm }}" required="required">
</div>
<div class="mb-3">
<label for="ipAddrs-{{ record.id }}" class="form-label">IP Address</label>
<input type="text" class="form-control" id="ipAddrs-{{ record.id }}" name="ip_addrs"
value="{{ record.ip_addrs }}" required="required">
</div>
<div class="mb-3">
<label for="svrNm-{{ record.id }}" class="form-label">Server Name</label>
<input type="text" class="form-control" id="svrNm-{{ record.id }}" name="svr_nm"
value="{{ record.svr_nm }}" required="required">
</div>
<div class="mb-3">
<label for="contents-{{ record.id }}" class="form-label">Location</label>
<textarea class="form-control" id="contents-{{ record.id }}" name="contents">{{ record.contents }}</textarea>
</div>
<div class="mb-3">
<label for="remark-{{ record.id }}" class="form-label">Remark</label>
<textarea class="form-control" id="remark-{{ record.id }}"
name="remark">{{ record.remark }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</div>
</div>
</div>
</div>
{% empty %}
<tr>
<td colspan="9" class="text-center">No records found.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if request.user.is_authenticated %}
<!-- 버튼 컨테이너 -->
<div class="d-flex justify-content-between align-items-center mb-3">
<!-- 데이터 등록 버튼 -->
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addDataModal">
<i class="bi bi-plus-lg"></i>
Add New IP Record
</button>
<!-- 전체 삭제 버튼 -->
<button type="submit" formaction="{% url 'butler:ip_mgmt_delete' %}" class="btn btn-danger">
<i class="bi bi-trash"></i>
Delete Selected
</button>
<!-- 수정 버튼 -->
<button type="button" id="editSelectedButton" class="btn btn-warning" disabled="disabled">
<i class="bi bi-pencil-square"></i>
Edit Selected
</button>
</div>
{% endif %}
</form>
<!-- 데이터 등록 모달 -->
<div class="modal fade" id="addDataModal" tabindex="-1" aria-labelledby="addDataModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="addDataModalLabel">Add New IP Record</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- 데이터 등록 폼 -->
<form id="addDataForm" method="post" action="{% url 'butler:ip_mgmt_add' %}">
{% csrf_token %}
<div class="mb-3">
<label for="networkNm" class="form-label">Network Name</label>
<input type="text" class="form-control" id="networkNm" name="network_nm" required="required">
</div>
<div class="mb-3">
<label for="ipAddrs" class="form-label">IP Address</label>
<input type="text" class="form-control" id="ipAddrs" name="ip_addrs" required="required">
</div>
<div class="mb-3">
<label for="svrNm" class="form-label">Server Name</label>
<input type="text" class="form-control" id="svrNm" name="svr_nm" required="required">
</div>
<div class="mb-3">
<label for="contents" class="form-label">Location</label>
<textarea class="form-control" id="contents" name="contents"></textarea>
</div>
<div class="mb-3">
<label for="remark" class="form-label">Remark</label>
<textarea class="form-control" id="remark" name="remark"></textarea>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const editButton = document.getElementById('editSelectedButton');
const checkboxes = document.querySelectorAll('.record-checkbox');
// Event listener to enable/disable the edit button
document.addEventListener('change', function () {
const selected = [...checkboxes].filter(checkbox => checkbox.checked);
if (selected.length === 1) {
editButton.disabled = false;
editButton.dataset.recordId = selected[0].value;
} else {
editButton.disabled = true;
delete editButton.dataset.recordId;
}
});
// Event listener to open the edit modal
editButton.addEventListener('click', function () {
const recordId = editButton.dataset.recordId;
if (recordId) {
const modal = new bootstrap.Modal(document.getElementById(`editDataModal-${recordId}`));
modal.show();
}
});
});
</script>
{% endblock %}

View File

@ -3,198 +3,183 @@
{% block title %}IP Management{% endblock %} {% block title %}IP Management{% endblock %}
{% block main_area %} {% block main_area %}
<h2 class="fw-bold pt-3 pb-2">IP 관리 대장</h2> <h2 class="fw-bold pt-3 pb-2">IP 관리 대장</h2>
{% if not request.user.is_authenticated %} {% if not request.user.is_authenticated %}
<p class="text-danger">비로그인 익명사용자로 접근 중입니다. <p class="text-danger">비로그인 익명사용자로 접근 중입니다.
<br>로그인시 로그인 사용자가 등록한 데이터만 조회됩니다. <br>로그인시 로그인 사용자가 등록한 데이터만 조회됩니다.
</p> </p>
{% endif %} {% endif %}
<!-- 검색 폼 --> <!-- 검색 폼 -->
<div class="row"> <div class="row">
<div class="col-4"> <div class="col-4"></div>
<div class="col-4"></div>
<div class="col-4">
<form method="get" class="mb-3">
<div class="input-group">
<input type="text" name="var_search" class="form-control" placeholder="Search by Author or Network Name..." value="{{ var_search }}">
<button type="submit" class="btn btn-outline-primary">Search</button>
</div>
</form>
</div>
</div> </div>
<div class="col-4">
</div>
<div class="col-4">
<form method="get" class="mb-3">
<div class="input-group">
<input type="text" name="var_search" class="form-control" placeholder="Search by Author or Network Name..."
value="{{ var_search }}">
<button type="submit" class="btn btn-outline-primary">Search</button>
</div>
</form>
</div>
</div>
<!-- IP 레코드 목록 -->
<form id="recordForm" method="post" action="">
{% csrf_token %}
<table class="table table-striped table-hover table-bordered">
<thead class="table-dark">
<tr>
<th scope="col">Select</th>
<th scope="col">Network Name</th>
<th scope="col">IP Address</th>
<th scope="col">Server Name</th>
<th scope="col">Description</th>
<th scope="col">Remark</th>
<th scope="col">Author</th>
<th scope="col">Updated At</th>
</tr>
</thead>
<tbody>
{% for record in records %}
<tr data-record-id="{{ record.id }}">
<td class="text-center">
<input type="checkbox" name="selected_records" value="{{ record.id }}"
class="form-check-input record-checkbox">
</td>
<td>{{ record.network_nm }}</td>
<td>{{ record.ip_addrs }}</td>
<td>{{ record.svr_nm }}</td>
<td>{{ record.desc }}</td>
<td>{{ record.remark }}</td>
<td>{{ record.author }}</td>
<td>{{ record.updated_at|date:"Y-m-d H:i" }}</td>
</tr>
<!-- 수정 모달 --> <!-- IP 레코드 목록 -->
<div class="modal fade" id="editDataModal-{{ record.id }}" tabindex="-1" <form id="recordForm" method="post" action="">
aria-labelledby="editDataModalLabel-{{ record.id }}" aria-hidden="true"> {% csrf_token %}
<div class="modal-dialog"> <table class="table table-striped table-hover table-bordered">
<div class="modal-content"> <thead class="table-dark">
<div class="modal-header bg-warning text-dark"> <tr>
<h5 class="modal-title" id="editDataModalLabel-{{ record.id }}">Edit IP Record</h5> <th scope="col">Select</th>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <th scope="col">Network Name</th>
</div> <th scope="col">Server Name</th>
<div class="modal-body"> <th scope="col">Location</th>
<!-- 데이터 수정 폼 --> <th scope="col">IP Address</th>
<form id="editDataForm-{{ record.id }}" method="post" action="{% url 'butler:ip_mgmt_edit' record.id %}"> <th scope="col">Remark</th>
{% csrf_token %} <th scope="col">Author</th>
<div class="mb-3"> <th scope="col">Updated At</th>
<label for="networkNm-{{ record.id }}" class="form-label">Network Name</label> </tr>
<input type="text" class="form-control" id="networkNm-{{ record.id }}" name="network_nm" </thead>
value="{{ record.network_nm }}" required="required"> <tbody>
</div> {% for record in records %}
<div class="mb-3"> <tr data-record-id="{{ record.id }}">
<label for="ipAddrs-{{ record.id }}" class="form-label">IP Address</label> <td class="text-center">
<input type="text" class="form-control" id="ipAddrs-{{ record.id }}" name="ip_addrs" <input type="checkbox" name="selected_records" value="{{ record.id }}" class="form-check-input record-checkbox">
value="{{ record.ip_addrs }}" required="required"> </td>
</div> <td>{{ record.network_nm }}</td>
<div class="mb-3"> <td>{{ record.svr_nm }}</td>
<label for="svrNm-{{ record.id }}" class="form-label">Server Name</label> <td>{{ record.contents }}</td>
<input type="text" class="form-control" id="svrNm-{{ record.id }}" name="svr_nm" <td>{{ record.ip_addrs }}</td>
value="{{ record.svr_nm }}" required="required"> <td>{{ record.remark }}</td>
</div> <td>{{ record.author }}</td>
<div class="mb-3"> <td>{{ record.updated_at|date:"Y-m-d H:i" }}</td>
<label for="desc-{{ record.id }}" class="form-label">Description</label> </tr>
<textarea class="form-control" id="desc-{{ record.id }}" name="desc">{{ record.desc }}</textarea> {% empty %}
</div> <tr>
<div class="mb-3"> <td colspan="9" class="text-center">No records found.</td>
<label for="remark-{{ record.id }}" class="form-label">Remark</label> </tr>
<textarea class="form-control" id="remark-{{ record.id }}" {% endfor %}
name="remark">{{ record.remark }}</textarea> </tbody>
</div> </table>
<button type="submit" class="btn btn-primary">Save Changes</button> {% if request.user.is_authenticated %}
</form> <div class="d-flex justify-content-between align-items-center mb-3">
</div> <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addDataModal">
<i class="bi bi-plus-lg"></i>
Add New IP Record
</button>
<button type="submit" formaction="{% url 'butler:ip_mgmt_delete' %}" class="btn btn-danger">
<i class="bi bi-trash"></i>
Delete Selected
</button>
<button type="button" id="editSelectedButton" class="btn btn-warning" disabled="disabled">
<i class="bi bi-pencil-square"></i>
Edit Selected
</button>
</div>
{% endif %}
</form>
<!-- 수정 모달들 (form 중첩 제거) -->
{% for record in records %}
<div class="modal fade" id="editDataModal-{{ record.id }}" tabindex="-1" aria-labelledby="editDataModalLabel-{{ record.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-warning text-dark">
<h5 class="modal-title" id="editDataModalLabel-{{ record.id }}">Edit IP Record</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="editDataForm-{{ record.id }}" method="post" action="{% url 'butler:ip_mgmt_edit' record.id %}{% if var_search %}?var_search={{ var_search }}{% endif %}">
{% csrf_token %}
{% if var_search %}<input type="hidden" name="var_search" value="{{ var_search }}">{% endif %}
<div class="mb-3">
<label for="networkNm-{{ record.id }}" class="form-label">Network Name</label>
<input type="text" class="form-control" id="networkNm-{{ record.id }}" name="network_nm" value="{{ record.network_nm }}" required="required">
</div>
<div class="mb-3">
<label for="ipAddrs-{{ record.id }}" class="form-label">IP Address</label>
<input type="text" class="form-control" id="ipAddrs-{{ record.id }}" name="ip_addrs" value="{{ record.ip_addrs }}" required="required">
</div>
<div class="mb-3">
<label for="svrNm-{{ record.id }}" class="form-label">Server Name</label>
<input type="text" class="form-control" id="svrNm-{{ record.id }}" name="svr_nm" value="{{ record.svr_nm }}" required="required">
</div>
<div class="mb-3">
<label for="contents-{{ record.id }}" class="form-label">Location</label>
<textarea class="form-control" id="contents-{{ record.id }}" name="contents">{{ record.contents }}</textarea>
</div>
<div class="mb-3">
<label for="remark-{{ record.id }}" class="form-label">Remark</label>
<textarea class="form-control" id="remark-{{ record.id }}" name="remark">{{ record.remark }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</div> </div>
</div> </div>
</div> </div>
{% empty %} </div>
<tr> {% endfor %}
<td colspan="9" class="text-center">No records found.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if request.user.is_authenticated %}
<!-- 버튼 컨테이너 -->
<div class="d-flex justify-content-between align-items-center mb-3">
<!-- 데이터 등록 버튼 -->
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addDataModal">
<i class="bi bi-plus-lg"></i>
Add New IP Record
</button>
<!-- 전체 삭제 버튼 --> <!-- 등록 모달 -->
<button type="submit" formaction="{% url 'butler:ip_mgmt_delete' %}" class="btn btn-danger"> <div class="modal fade" id="addDataModal" tabindex="-1" aria-labelledby="addDataModalLabel" aria-hidden="true">
<i class="bi bi-trash"></i> <div class="modal-dialog">
Delete Selected <div class="modal-content">
</button> <div class="modal-header bg-success text-white">
<h5 class="modal-title" id="addDataModalLabel">Add New IP Record</h5>
<!-- 수정 버튼 --> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<button type="button" id="editSelectedButton" class="btn btn-warning" disabled="disabled"> </div>
<i class="bi bi-pencil-square"></i> <div class="modal-body">
Edit Selected <form id="addDataForm" method="post" action="{% url 'butler:ip_mgmt_add' %}">
</button> {% csrf_token %}
</div> <div class="mb-3">
{% endif %} <label for="networkNm" class="form-label">Network Name</label>
</form> <input type="text" class="form-control" id="networkNm" name="network_nm" required="required">
</div>
<!-- 데이터 등록 모달 --> <div class="mb-3">
<div class="modal fade" id="addDataModal" tabindex="-1" aria-labelledby="addDataModalLabel" aria-hidden="true"> <label for="ipAddrs" class="form-label">IP Address</label>
<div class="modal-dialog"> <input type="text" class="form-control" id="ipAddrs" name="ip_addrs" required="required">
<div class="modal-content"> </div>
<div class="modal-header bg-success text-white"> <div class="mb-3">
<h5 class="modal-title" id="addDataModalLabel">Add New IP Record</h5> <label for="svrNm" class="form-label">Server Name</label>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <input type="text" class="form-control" id="svrNm" name="svr_nm" required="required">
</div> </div>
<div class="modal-body"> <div class="mb-3">
<!-- 데이터 등록 폼 --> <label for="contents" class="form-label">Location</label>
<form id="addDataForm" method="post" action="{% url 'butler:ip_mgmt_add' %}"> <textarea class="form-control" id="contents" name="contents"></textarea>
{% csrf_token %} </div>
<div class="mb-3"> <div class="mb-3">
<label for="networkNm" class="form-label">Network Name</label> <label for="remark" class="form-label">Remark</label>
<input type="text" class="form-control" id="networkNm" name="network_nm" required="required"> <textarea class="form-control" id="remark" name="remark"></textarea>
</div> </div>
<div class="mb-3"> <button type="submit" class="btn btn-primary">Submit</button>
<label for="ipAddrs" class="form-label">IP Address</label> </form>
<input type="text" class="form-control" id="ipAddrs" name="ip_addrs" required="required"> </div>
</div>
<div class="mb-3">
<label for="svrNm" class="form-label">Server Name</label>
<input type="text" class="form-control" id="svrNm" name="svr_nm" required="required">
</div>
<div class="mb-3">
<label for="desc" class="form-label">Description</label>
<textarea class="form-control" id="desc" name="desc"></textarea>
</div>
<div class="mb-3">
<label for="remark" class="form-label">Remark</label>
<textarea class="form-control" id="remark" name="remark"></textarea>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div> </div>
</div> </div>
</div> </div>
</div>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const editButton = document.getElementById('editSelectedButton'); const editButton = document.getElementById('editSelectedButton');
const checkboxes = document.querySelectorAll('.record-checkbox'); const checkboxes = document.querySelectorAll('.record-checkbox');
// Event listener to enable/disable the edit button document.addEventListener('change', function () {
document.addEventListener('change', function () { const selected = [...checkboxes].filter(checkbox => checkbox.checked);
const selected = [...checkboxes].filter(checkbox => checkbox.checked); if (selected.length === 1) {
if (selected.length === 1) { editButton.disabled = false;
editButton.disabled = false; editButton.dataset.recordId = selected[0].value;
editButton.dataset.recordId = selected[0].value; } else {
} else { editButton.disabled = true;
editButton.disabled = true; delete editButton.dataset.recordId;
delete editButton.dataset.recordId; }
} });
editButton.addEventListener('click', function () {
const recordId = editButton.dataset.recordId;
if (recordId) {
const modal = new bootstrap.Modal(document.getElementById(`editDataModal-${recordId}`));
modal.show();
}
});
}); });
</script>
// Event listener to open the edit modal {% endblock %}
editButton.addEventListener('click', function () {
const recordId = editButton.dataset.recordId;
if (recordId) {
const modal = new bootstrap.Modal(document.getElementById(`editDataModal-${recordId}`));
modal.show();
}
});
});
</script>
{% endblock %}

View File

@ -3,11 +3,46 @@
{% block title %}Landing Page{% endblock %} {% block title %}Landing Page{% endblock %}
{% block main_area %} {% block main_area %}
<article class="pt-3"> <div class="container mt-5">
<h2 class="fw-bold pb-2">Welcome!</h2> <div class="row">
<h3>IT Infra 및 DevOps 자원관리 도구</h3> <!-- NoticeBoard Section -->
* IP리스트 관리기능 제공 <br> <div class="col-md-6">
* Public NHN Cloud API 기능 일부 제공 <h2>Latest Notices</h2>
<p>계속 기능 구현 중 입니다.</p> <div class="row">
</article> {% for notice in board_notices %}
<div class="col-12 mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ notice.title }}</h5>
<p class="card-text">{{ notice.contents|truncatechars:50 }}</p>
<a href="{{ notice.get_absolute_url }}" class="btn btn-primary">View Notice</a>
</div>
</div>
</div>
{% empty %}
<p>No notices available.</p>
{% endfor %}
</div>
</div>
<!-- Blog Posts Section -->
<div class="col-md-6">
<h2>Latest Blog Posts</h2>
<div class="row">
{% for post in blog_posts %}
<div class="col-12 mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ post.title }}</h5>
<p class="card-text">{{ post.contents|truncatechars:50 }}</p>
<a href="{% url 'blog:post_detail' post.id %}" class="btn btn-primary">Read More</a>
</div>
</div>
</div>
{% empty %}
<p>No blog posts available.</p>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,19 +0,0 @@
{% extends "components/base.html" %}
{% block title %}Notice Detail{% endblock %}
{% block main_area %}
<h2 class="fw-bold pt-3 pb-2">{{ notice.title }}</h2>
<hr>
<div>
{{ notice.contents|linebreaks }}
</div>
<hr>
<p class="text-muted">작성자:
{{ notice.author }}</p>
<p class="text-muted">작성일:
{{ notice.created_at|date:"Y-m-d H:i" }}</p>
<p class="text-muted">수정일:
{{ notice.updated_at|date:"Y-m-d H:i" }}</p>
<a href="{% url 'butler:notice_list' %}" class="btn btn-secondary mt-3">Back to Notices</a>
{% endblock %}

View File

@ -8,7 +8,7 @@
<!-- Grafana IFrame --> <!-- Grafana IFrame -->
<div class="ratio ratio-16x9"> <div class="ratio ratio-16x9">
<iframe <iframe
src="https://grafana.nativedeck.com/d/PwMJtdvnz/1-k8s-for-prometheus-dashboard-20211010-en?orgId=2&from=1731468637504&to=1731470437504&kiosk" src="https://grafana.icurfer.com/public-dashboards/40d2a2615010433d81f5cf40caa03541"
width="100%" width="100%"
height="800" height="800"
frameborder="0" frameborder="0"

View File

@ -4,14 +4,17 @@ from . import views
app_name = 'butler' app_name = 'butler'
urlpatterns = [ urlpatterns = [
path('', views.hello_view, name='landing'), # 루트 경로에서 hello_view 호출 # path('', views.hello_view, name='landing'), # 루트 경로에서 hello_view 호출
path('notice', views.notice_list, name='notice_list'), # Landing Page
path('create_notice/', views.create_notice, name='create_notice'), # 포스트 작성 path('', views.LandingPageView.as_view(), name='landing'), # 클래스 기반 뷰(CBV) 호출
path('notice/<int:pk>/', views.notice_detail_view, name='notice_detail'),
# path('notice', views.notice_list, name='notice_list'),
# path('create_notice/', views.create_notice, name='create_notice'), # 포스트 작성
# path('notice/<int:pk>/', views.notice_detail_view, name='notice_detail'),
path('ip_mgmt', views.ip_mgmt_view, name='ip_mgmt'), path('ip_mgmt', views.ip_mgmt_view, name='ip_mgmt'),
path('ip-mgmt/add/', views.add_ip_record, name='ip_mgmt_add'), # 데이터 추가 경로 path('ip-mgmt/add/', views.add_ip_record, name='ip_mgmt_add'), # 데이터 추가 경로
path('ip-mgmt/delete/', views.delete_ip_records, name='ip_mgmt_delete'), # 삭제 URL 추가 path('ip-mgmt/delete/', views.delete_ip_records, name='ip_mgmt_delete'), # 삭제 URL 추가
path('ip-mgmt/edit/<int:pk>/', views.edit_ip_record, name='ip_mgmt_edit'), # 수정 URL 추가 path('ip-mgmt/edit/<int:pk>/', views.edit_ip_record, name='ip_mgmt_edit'), # 수정 URL 추가
path('privacy/', views.privacy_view, name='privacy'), path('privacy/', views.privacy_view, name='privacy'),
path('test/', views.test_view, name='test'), # path('test/', views.test_view, name='test'),
] ]

View File

@ -1,47 +1,29 @@
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.views.generic import TemplateView
from pathlib import Path from pathlib import Path
import markdown import markdown
import os import os
from .models import NoticeBoard, IPManagementRecord import logging # 2025-04-14 Log 등록
from .forms import PostForm from .models import IPManagementRecord
from blog.models import Post
from board_notice.models import BoardNotice
from django.db.models import Q from django.db.models import Q
from opentelemetry import trace
# 2025-04-14 Log & Trace 등록
logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__) # 트레이서 가져오기
def hello_view(request): class LandingPageView(TemplateView):
return render( template_name = "butler/landing.html"
request,
"butler/landing.html",
)
# --- notice ---
@login_required
def create_notice(request):
if request.method == 'POST':
form = PostForm(request.POST)
if form.is_valid():
post = form.save(commit=False)
post.author = request.user # 작성자 정보 추가
post.save()
form.save_m2m()
return redirect('butler:notice_list')
else:
form = PostForm()
return render(request, 'butler/create_notice.html', {'form': form})
def notice_list(request):
records = NoticeBoard.objects.all()
return render(request, "butler/notice_list.html", {"records": records})
def notice_detail_view(request, pk):
try:
notice = NoticeBoard.objects.get(pk=pk)
except NoticeBoard.DoesNotExist:
return render(request, "404.html") # 선택적으로 에러 처리
return render(request, "butler/notice_detail.html", {"notice": notice})
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# context['var'] var를 templates로 전달해서 보여지는 것.
context['blog_posts'] = Post.objects.order_by('-created_at')[:3]
context['board_notices'] = BoardNotice.objects.order_by('-created_at')[:3]
return context
# --- ip management --- # --- ip management ---
# def ip_mgmt_view(request): # def ip_mgmt_view(request):
@ -79,53 +61,96 @@ def ip_mgmt_view(request):
records = records.order_by("ip_addrs") records = records.order_by("ip_addrs")
return render(request, "butler/ip_mgmt.html", {"records": records, "var_search": query}) return render(request, "butler/ip_mgmt.html", {"records": records, "var_search": query})
def add_ip_record(request): def add_ip_record(request):
if request.method == "POST": if request.method == "POST":
network_nm = request.POST.get("network_nm") network_nm = request.POST.get("network_nm")
ip_addrs = request.POST.get("ip_addrs") ip_addrs = request.POST.get("ip_addrs")
svr_nm = request.POST.get("svr_nm") svr_nm = request.POST.get("svr_nm")
desc = request.POST.get("desc") contents = request.POST.get("contents")
remark = request.POST.get("remark") remark = request.POST.get("remark")
# 작성자 (author)는 로그인된 사용자로 설정
author = request.user author = request.user
# 데이터 저장
IPManagementRecord.objects.create( # 2025-04-14 트레이스 span 생성
network_nm=network_nm, with tracer.start_as_current_span("create_ip_record") as span:
ip_addrs=ip_addrs, # 데이터 저장
svr_nm=svr_nm, IPManagementRecord.objects.create(
desc=desc, network_nm=network_nm,
remark=remark, ip_addrs=ip_addrs,
author=author, svr_nm=svr_nm,
) contents=contents,
remark=remark,
author=author,
)
# 2025-04-14 Jaeger용 Event 추가
span.add_event(
"Saved IPManagementRecord",
attributes={
"ip_addrs": ip_addrs,
"svr_nm": svr_nm,
"desc": "jager logs test",
}
)
# 2025-04-14 로그 등록 (콘솔+FluentBit용)
logger.info(f"Create_Record_IP_ADDRS: {ip_addrs}")
return redirect("/ip_mgmt") return redirect("/ip_mgmt")
def delete_ip_records(request): def delete_ip_records(request):
print(f"삭제동작") # print(f"Delete_Record_IP_ADDRS")
if request.method == "POST": if request.method == "POST":
selected_ids = request.POST.getlist("selected_records") selected_ids = request.POST.getlist("selected_records")
if selected_ids: if selected_ids:
IPManagementRecord.objects.filter(id__in=selected_ids).delete() IPManagementRecord.objects.filter(id__in=selected_ids).delete()
# 2025-04-14 Log 등록
logger.info(f"Delete_Record_IP_ADDRS_idx: {selected_ids}")
return redirect("/ip_mgmt") return redirect("/ip_mgmt")
def edit_ip_record(request, pk): def edit_ip_record(request, pk):
print(f"수정동작") # print(f"Edit_Record_IP_ADDRS")
record = get_object_or_404(IPManagementRecord, pk=pk) record = get_object_or_404(IPManagementRecord, pk=pk)
# 검색 키워드 유지
var_search = request.GET.get("var_search") or request.POST.get("var_search")
if request.method == "POST": if request.method == "POST":
# 디버깅 메시지 추가 # 2025-04-14 trace span
# print(f"체크: {request.POST}") with tracer.start_as_current_span("edit_ip_record") as span:
record.network_nm = request.POST.get("network_nm") record.network_nm = request.POST.get("network_nm")
record.ip_addrs = request.POST.get("ip_addrs") record.ip_addrs = request.POST.get("ip_addrs")
record.svr_nm = request.POST.get("svr_nm") record.svr_nm = request.POST.get("svr_nm")
record.desc = request.POST.get("desc") record.contents = request.POST.get("contents")
record.remark = request.POST.get("remark") record.remark = request.POST.get("remark")
record.save() record.save()
# 2025-04-14 Jaeger용 Event 추가
span.add_event(
"Edit IPManagementRecord",
attributes={
"ip_addrs": record.ip_addrs,
"svr_nm": record.svr_nm,
"location": record.contents,
"remark": record.remark,
"desc": "jager event log insert",
}
)
# 2025-04-14 Log 등록
logger.info(f"Edit_Record_IP_ADDRS: {record.ip_addrs}")
if var_search:
return redirect(f"/ip_mgmt?var_search={var_search}")
return redirect("/ip_mgmt") return redirect("/ip_mgmt")
return render(request, "butler/ip_mgmt.html", {"record": record}) return render(request, "butler/ip_mgmt.html", {"record": record})
# --- privacy # --- privacy
def privacy_view(request): def privacy_view(request):
# 'docs/privacy.md' 파일을 읽기 # 'docs/privacy.md' 파일을 읽기

157
butler/views.py.bakcup Normal file
View File

@ -0,0 +1,157 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.views.generic import TemplateView
from pathlib import Path
import markdown
import os
import logging # 2025-04-14 Log 등록
from .models import IPManagementRecord
from blog.models import Post
from board_notice.models import BoardNotice
from django.db.models import Q
# 2025-04-14 Log 등록
logger = logging.getLogger(__name__)
class LandingPageView(TemplateView):
template_name = "butler/landing.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# context['var'] var를 templates로 전달해서 보여지는 것.
context['blog_posts'] = Post.objects.order_by('-created_at')[:3]
context['board_notices'] = BoardNotice.objects.order_by('-created_at')[:3]
return context
# --- ip management ---
# def ip_mgmt_view(request):
# # records = IPManagementRecord.objects.all()
# if request.user.is_authenticated:
# # records = IPManagementRecord.objects.filter(author=request.user).order_by(
# records = IPManagementRecord.objects.order_by(
# "ip_addrs"
# )
# else:
# # records = IPManagementRecord.objects.none()
# records = IPManagementRecord.objects.all().order_by("ip_addrs")
# return render(request, "butler/ip_mgmt.html", {"records": records})
def ip_mgmt_view(request):
query = request.GET.get("var_search", "").strip()
records = IPManagementRecord.objects.order_by(
"ip_addrs"
)
if query:
records = records.filter(
# Q(author__username__icontains=query) | Q(author__email__icontains=query)
Q(author__username__icontains=query) | Q(network_nm__icontains=query)
)
"""
SELECT * FROM ip_management_record AS ip JOIN auth_user AS au
ON ip.author_id = au.id
WHERE au.username LIKE CONCAT('%', 조회할 값, '%')
OR au.email LIKE CONCAT('%', 조회할 값, '%')
ORDER BY ip.ip_addrs;
"""
records = records.order_by("ip_addrs")
return render(request, "butler/ip_mgmt.html", {"records": records, "var_search": query})
def add_ip_record(request):
# print(f"Create_Record_IP_ADDRS")
if request.method == "POST":
network_nm = request.POST.get("network_nm")
ip_addrs = request.POST.get("ip_addrs")
svr_nm = request.POST.get("svr_nm")
contents = request.POST.get("contents")
remark = request.POST.get("remark")
# 작성자 (author)는 로그인된 사용자로 설정
author = request.user
# 데이터 저장
IPManagementRecord.objects.create(
network_nm=network_nm,
ip_addrs=ip_addrs,
svr_nm=svr_nm,
contents=contents,
remark=remark,
author=author,
)
# 2025-04-14 Log 등록
logger.info(f"Create_Record_IP_ADDRS: {ip_addrs}")
return redirect("/ip_mgmt")
def delete_ip_records(request):
# print(f"Delete_Record_IP_ADDRS")
if request.method == "POST":
selected_ids = request.POST.getlist("selected_records")
if selected_ids:
IPManagementRecord.objects.filter(id__in=selected_ids).delete()
# 2025-04-14 Log 등록
logger.info(f"Delete_Record_IP_ADDRS_idx: {selected_ids}")
return redirect("/ip_mgmt")
def edit_ip_record(request, pk):
# print(f"Edit_Record_IP_ADDRS")
record = get_object_or_404(IPManagementRecord, pk=pk)
# 검색 키워드 유지
var_search = request.GET.get("var_search") or request.POST.get("var_search")
if request.method == "POST":
record.network_nm = request.POST.get("network_nm")
record.ip_addrs = request.POST.get("ip_addrs")
record.svr_nm = request.POST.get("svr_nm")
record.contents = request.POST.get("contents")
record.remark = request.POST.get("remark")
record.save()
# 2025-04-14 Log 등록
logger.info(f"Edit_Record_IP_ADDRS: {record.ip_addrs}")
if var_search:
return redirect(f"/ip_mgmt?var_search={var_search}")
return redirect("/ip_mgmt")
return render(request, "butler/ip_mgmt.html", {"record": record})
# --- privacy
def privacy_view(request):
# 'docs/privacy.md' 파일을 읽기
file_path = os.path.join("docs", "docs_md_files/privacy.md")
with open(file_path, "r", encoding="utf-8") as file:
text = file.read()
file_path = Path("docs/docs_md_files/privacy.md")
with file_path.open("r", encoding="utf-8") as file:
text = file.read()
# Markdown을 HTML로 변환
# html_content = markdown.markdown(text)
# Markdown을 HTML로 변환, tables 확장 활성화
html_content = markdown.markdown(
text,
extensions=[
"tables", # 테이블 지원 확장
],
)
# 변환된 HTML을 템플릿에 전달
return render(request, "butler/privacy.html", {"content": html_content})
def test_view(request):
return render(
request,
"butler/test.html",
)

View File

@ -16,7 +16,17 @@ from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(os.path.join(BASE_DIR, '.env.dev')) # 우선순위: .env.dev > .env.prd > .env
if os.path.exists(os.path.join(BASE_DIR, '.env.dev')):
print("Read Environment File > Used : .env.dev")
load_dotenv(os.path.join(BASE_DIR, '.env.dev'))
elif os.path.exists(os.path.join(BASE_DIR, '.env.prd')):
print("Read Environment File > Used : .env.prd")
load_dotenv(os.path.join(BASE_DIR, '.env.prd'))
else:
print("None Environment File > Used : local_env")
# load_dotenv(os.path.join(BASE_DIR, '.env.dev'))
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
@ -26,7 +36,59 @@ SECRET_KEY = 'django-insecure-fh+awf3$$^el9(#*-dpuv&++#rck@1+s=o1mx+#etv)!lpq@_5
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get('DEBUG', 1)) DEBUG = int(os.environ.get('DEBUG', 1))
LOGGING = {
'version': 1,
'disable_existing_loggers': False, # Django 기본 로거 유지
'formatters': {
'standard': {
'format': '[{asctime}] {levelname} {name}:{lineno} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler', # 콘솔 출력
'formatter': 'standard',
},
},
'root': {
'handlers': ['console'],
'level': 'INFO', # 기본 레벨 (애플리케이션 코드)
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'INFO', # Django 프레임워크 전반
'propagate': False,
},
'django.request': {
'handlers': ['console'],
'level': 'ERROR', # 요청 관련 에러만 (500 에러 같은 것)
'propagate': False,
},
'django.db.backends': {
'handlers': ['console'],
'level': 'WARNING', # DB 쿼리 경고만
'propagate': False,
},
'django.security': {
'handlers': ['console'],
'level': 'WARNING', # 보안 관련 경고
'propagate': False,
},
# mattermost send message log 너무 많이 나와서 조정
'apscheduler': {
'handlers': ['console'],
'level': 'WARNING', # INFO 로그 안 보이게 함 | 'CRITICAL'로 맞추면 사실상 아무것도 안 찍힘
'propagate': False,
},
},
}
# if DEBUG:
# LOGGING['loggers']['django.db.backends']['level'] = 'DEBUG'
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
@ -41,6 +103,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'taggit', 'taggit',
# Custom by.icurfer # Custom by.icurfer
'board_notice',
'custom_auth', 'custom_auth',
'butler', 'butler',
'blog', 'blog',
@ -50,6 +113,8 @@ INSTALLED_APPS = [
'nhnc_mgmt', 'nhnc_mgmt',
'mm_msg', 'mm_msg',
'ansible_manager', 'ansible_manager',
'obs_minio',
'telemetry_dashboard',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -166,6 +231,7 @@ LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/'
# MinIO 설정 # MinIO 설정
# MINIO_STORAGE_MEDIA_URL = os.environ.get('MINIO_STORAGE_MEDIA_URL', '') MINIO_ENDPOINT_URL = os.environ.get('MINIO_ENDPOINT_URL', '')
# MINIO_STORAGE_ACCESS_KEY = os.environ.get('MINIO_STORAGE_ACCESS_KEY', '') MINIO_ACCESS_KEY = os.environ.get('MINIO_ACCESS_KEY', '')
# MINIO_STORAGE_SECRET_KEY = os.environ.get('MINIO_STORAGE_SECRET_KEY', '') MINIO_SECRET_KEY = os.environ.get('MINIO_SECRET_KEY', '')
MINIO_DEFAULT_BUCKET=os.environ.get('MINIO_DEFAULT_BUCKET', '')

View File

@ -24,11 +24,14 @@ urlpatterns = [
path('', include('butler.urls')), path('', include('butler.urls')),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('accounts/', include('custom_auth.urls')), # custom_auth URL 추가 path('accounts/', include('custom_auth.urls')), # custom_auth URL 추가
path('notice/', include('board_notice.urls')),
path('blog/', include('blog.urls')),
path('nhncloud/', include('nhncloud.urls')), path('nhncloud/', include('nhncloud.urls')),
path('nhnc_mgmt/', include('nhnc_mgmt.urls')), path('nhnc_mgmt/', include('nhnc_mgmt.urls')),
path('mm_msg/', include('mm_msg.urls')), path('mm_msg/', include('mm_msg.urls')),
path('blog/', include('blog.urls')),
path('ansible_manager/', include('ansible_manager.urls')), path('ansible_manager/', include('ansible_manager.urls')),
path('obs_minio/', include('obs_minio.urls')),
path('tm_dsbd/', include('telemetry_dashboard.urls')),
] ]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -1,16 +1,38 @@
"""
WSGI config for butler_ddochi project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os import os
# ✅ Django 설정을 미리 불러온다
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'butler_ddochi.settings')
from django.conf import settings # <<<< 이거 추가
# ✅ DEBUG 모드 아닐 때만 OpenTelemetry 초기화
if not settings.DEBUG:
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.django import DjangoInstrumentor
trace.set_tracer_provider(
TracerProvider(
resource=Resource.create({
"service.name": "butler_ddochi",
})
)
)
otlp_exporter = OTLPSpanExporter(
endpoint="http://jaeger-collector.istio-system:4317",
insecure=True,
)
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(otlp_exporter)
)
DjangoInstrumentor().instrument()
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'butler_ddochi.settings')
application = get_wsgi_application() application = get_wsgi_application()

View File

@ -0,0 +1,16 @@
"""
WSGI config for butler_ddochi project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'butler_ddochi.settings')
application = get_wsgi_application()

View File

@ -0,0 +1,8 @@
/* 이미지가 div 크기 넘지 않도록 */
#post-content img {
max-width: 100%;
height: auto;
display: block;
/*margin: 0 auto; (선택) 가운데 정렬 */
}

View File

@ -1,40 +1,38 @@
<div class="container-fluid d-flex justify-content-between align-items-center"> <div class="container-fluid d-flex justify-content-between align-items-center">
<h1 class="d-flex align-items-center fs-4 text-white mb-0"> <h1 class="d-flex align-items-center fs-4 text-white mb-0">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
<i class="fa-solid fa-dog"></i> DDoCHI</a> <i class="fa-solid fa-dog"></i>
DDoCHI</a>
</h1> </h1>
<!-- 로그인/로그아웃 및 회원가입 버튼 --> <!-- 로그인/로그아웃 및 회원가입 버튼 -->
<ul class="navbar-nav flex-row"> <ul class="navbar-nav flex-row">
{% if user.is_authenticated and user.is_superuser %} {% if user.is_authenticated and user.is_superuser %}
<!-- 관리자 콘솔 버튼 --> <!-- 관리자 콘솔 버튼 -->
<li class="nav-item me-3"> <li class="nav-item me-3">
<a href="/admin" target="_blank" class="btn btn-outline-warning mb-2">ADMIN</a> <a href="/admin" target="_blank" class="btn btn-outline-warning mb-2">ADMIN</a>
</li> </li>
{% endif %} {% endif %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<!-- 회원정보 변경 버튼 --> <!-- 회원정보 변경 버튼 -->
<li class="nav-item me-3"> <li class="nav-item me-3">
<button type="button" class="btn btn-outline-info mb-2" data-bs-toggle="modal" <button type="button" class="btn btn-outline-info mb-2" data-bs-toggle="modal" data-bs-target="#editProfileModal">Edit Profile</button>
data-bs-target="#editProfileModal">Edit Profile</button> </li>
</li> <!-- 로그아웃 버튼 -->
<!-- 로그아웃 버튼 --> <li class="nav-item">
<li class="nav-item"> <form method="post" action="{% url 'custom_auth:logout' %}">
<form method="post" action="{% url 'custom_auth:logout' %}"> {% csrf_token %}
{% csrf_token %} <button type="submit" class="btn btn-outline-danger w-100">Logout</button>
<button type="submit" class="btn btn-outline-danger w-100">Logout</button> </form>
</form> </li>
</li>
{% else %} {% else %}
<!-- 로그인 버튼 --> <!-- 로그인 버튼 -->
<li class="nav-item me-3"> <li class="nav-item me-3">
<button type="button" class="btn btn-outline-primary mb-2" data-bs-toggle="modal" <button type="button" class="btn btn-outline-primary mb-2" data-bs-toggle="modal" data-bs-target="#loginModal">Login</button>
data-bs-target="#loginModal">Login</button> </li>
</li> <!-- 회원가입 버튼 -->
<!-- 회원가입 버튼 --> <li class="nav-item me-3">
<li class="nav-item me-3"> <button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#signupModal">Sign Up</button>
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#signupModal">Sign </li>
Up</button>
</li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@ -50,11 +48,11 @@
<div class="modal-body"> <div class="modal-body">
<!-- 로그인 실패 메시지 표시 --> <!-- 로그인 실패 메시지 표시 -->
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
{{ message }} {{ message }}
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<!-- 로그인 폼 --> <!-- 로그인 폼 -->
@ -123,7 +121,7 @@
{% csrf_token %} {% csrf_token %}
<div class="mb-3"> <div class="mb-3">
<label for="emailEdit" class="form-label">Email</label> <label for="emailEdit" class="form-label">Email</label>
<input type="email" class="form-control" id="emailEdit" name="email" value="{{ request.user.email }}" required> <input type="email" class="form-control" id="emailEdit" name="email" value="{{ request.user.email }}" required="required">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="nhncIdEdit" class="form-label">NHNC ID</label> <label for="nhncIdEdit" class="form-label">NHNC ID</label>
@ -133,6 +131,56 @@
<label for="nhncApiTenantIdEdit" class="form-label">NHNC API Tenant ID</label> <label for="nhncApiTenantIdEdit" class="form-label">NHNC API Tenant ID</label>
<input type="text" class="form-control" id="nhncApiTenantIdEdit" name="nhnc_api_tenant_id" value="{{ request.user.nhnc_api_tenant_id }}"> <input type="text" class="form-control" id="nhncApiTenantIdEdit" name="nhnc_api_tenant_id" value="{{ request.user.nhnc_api_tenant_id }}">
</div> </div>
<!-- 추가된 URL 필드 -->
<div class="mb-3">
<label for="urlGiteaEdit" class="form-label">Gitea URL</label>
<input type="url" class="form-control" id="urlGiteaEdit" name="url_gitea" value="{{ request.user.url_gitea }}">
</div>
<div class="mb-3">
<label for="urlHarborEdit" class="form-label">Harbor URL</label>
<input type="url" class="form-control" id="urlHarborEdit" name="url_harbor" value="{{ request.user.url_harbor }}">
</div>
<div class="mb-3">
<label for="urlArgoCDEdit" class="form-label">ArgoCD URL</label>
<input type="url" class="form-control" id="urlArgoCDEdit" name="url_argocd" value="{{ request.user.url_argocd }}">
</div>
<div class="mb-3">
<label for="urlWebIdeEdit" class="form-label">Web IDE URL</label>
<input type="url" class="form-control" id="urlWebIdeEdit" name="url_web_ide" value="{{ request.user.url_web_ide }}">
</div>
<div class="mb-3">
<label for="urlRancherEdit" class="form-label">Rancher URL</label>
<input type="url" class="form-control" id="urlRancherEdit" name="url_rancher" value="{{ request.user.url_rancher }}">
</div>
<div class="mb-3">
<label for="urlGrafanaEdit" class="form-label">Grafana URL</label>
<input type="url" class="form-control" id="urlGrafanaEdit" name="url_grafana" value="{{ request.user.url_grafana }}">
</div>
<div class="mb-3">
<label for="urlGrafanaDashboard_01-Edit" class="form-label">Grafana Dashboard URL</label>
<input type="url" class="form-control" id="urlGrafanaDashboard01Edit" name="url_grafana_dashboard_01" value="{{ request.user.url_grafana_dashboard_01 }}">
</div>
<div class="mb-3">
<label for="urlPrometheusEdit" class="form-label">Prometheus URL</label>
<input type="url" class="form-control" id="urlPrometheusEdit" name="url_prometheus" value="{{ request.user.url_prometheus }}">
</div>
<div class="mb-3">
<label for="urlOpensearchEdit" class="form-label">OpenSearch URL</label>
<input type="url" class="form-control" id="urlOpensearchEdit" name="url_opensearch" value="{{ request.user.url_opensearch }}">
</div>
<div class="mb-3">
<label for="urlKialiEdit" class="form-label">Kiali URL</label>
<input type="url" class="form-control" id="urlKialiEdit" name="url_kiali" value="{{ request.user.url_kiali }}">
</div>
<div class="mb-3">
<label for="urlNexusEdit" class="form-label">Nexus URL</label>
<input type="url" class="form-control" id="urlNexusEdit" name="url_nexus" value="{{ request.user.url_nexus }}">
</div>
<div class="mb-3">
<label for="urlMattermostEdit" class="form-label">Mattermost URL</label>
<input type="url" class="form-control" id="urlMattermostEdit" name="url_mattermost" value="{{ request.user.url_mattermost }}">
</div>
<!-- 비밀번호 변경 -->
<div class="mb-3"> <div class="mb-3">
<label for="passwordEdit" class="form-label">새 비밀번호</label> <label for="passwordEdit" class="form-label">새 비밀번호</label>
<input type="password" class="form-control" id="passwordEdit" name="password"> <input type="password" class="form-control" id="passwordEdit" name="password">

View File

@ -2,15 +2,16 @@
<nav class="sticky-xl-top small" id="toc"> <nav class="sticky-xl-top small" id="toc">
<ul class="list-unstyled"> <ul class="list-unstyled">
<li class="my-2"> <li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#about-collapse" aria-controls="about-collapse">About</button> <button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#about-collapse" aria-controls="about-collapse">About</button>
<ul class="list-unstyled ps-3 collapse" id="about-collapse"> <ul class="list-unstyled ps-3 collapse" id="about-collapse">
<li> <li>
<a class="d-inline-flex align-items-center rounded" href="/notice">공지사항</a> <a class="d-inline-flex align-items-center rounded" href="/notice">공지사항</a>
</li> </li>
</ul> </ul>
</li> </li>
<hr>
<li class="my-2"> <li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#contents-collapse" aria-controls="contents-collapse">Contents</button> <button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#contents-collapse" aria-controls="contents-collapse">Contents</button>
<ul class="list-unstyled ps-3 collapse" id="contents-collapse"> <ul class="list-unstyled ps-3 collapse" id="contents-collapse">
<li> <li>
<a class="d-inline-flex align-items-center rounded" href="/blog">Post</a> <a class="d-inline-flex align-items-center rounded" href="/blog">Post</a>
@ -19,7 +20,7 @@
<a class="d-inline-flex align-items-center rounded" href="/ip_mgmt">IP관리대장</a> <a class="d-inline-flex align-items-center rounded" href="/ip_mgmt">IP관리대장</a>
</li> </li>
<li> <li>
<a class="d-inline-flex align-items-center rounded" href="/game/">game(kimchirun)</a> <a class="d-inline-flex align-items-center rounded" href="/game/" target="_blank">game(kimchirun)</a>
</li> </li>
{% if request.user.is_authenticated and request.user.is_staff %} {% if request.user.is_authenticated and request.user.is_staff %}
<li> <li>
@ -27,107 +28,22 @@
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
</li> </li>
<hr>
{% include "components/_sidebar_tm_dashboard.html" %}
<hr>
{% include "components/_sidebar_nhn.html" %}
<hr>
{% include "components/_sidebar_devtools.html" %}
<hr> <hr>
<li class="my-2"> <li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#nhnc-collapse" aria-controls="nhnc-collapse">NHN Cloud API</button> <button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#other-collapse" aria-controls="other-collapse">Other Tools ▽</button>
<ul class="list-unstyled ps-3 collapse" id="nhnc-collapse">
<span>가이드</span>
{% if request.user.is_authenticated and request.user.is_staff %}
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/test">출력결과 검토</a>
</li>
{% endif %}
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/preparations">주의 사항</a>
</li>
<hr>
<span>관리대장</span>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhnc_mgmt/igw">인터넷 게이트웨이</a>
</li>
<hr>
<span>Network</span>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/infoVpcListRequest">VPC 조회 및 삭제</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/createVpcRequest">VPC 생성</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/infoClusterListRequest">NKS Cluseter 조회 및 삭제</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/createClusterOnlyRequest">NKS Cluseter 생성</a>
</li>
<hr>
<span>k8s Components deploy</span>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/nksApply">Ingress Deploy</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/nksApply">FluentBit Deploy</a>
</li>
<li>
<a class="text-secondary d-inline-flex align-items-center rounded disabled" href="#">Logging Deploy(미구현)</a>
</li>
<li>
<a class="text-secondary d-inline-flex align-items-center rounded disabled" href="#">Monitoring Deploy(미구현)</a>
</li>
</ul>
</li>
<hr>
<li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#components-collapse" aria-controls="components-collapse">DevOpsTools</button>
<ul class="list-unstyled ps-3 collapse" id="components-collapse">
<span>Dev</span>
<li>
<a class="d-inline-flex align-items-center rounded" href="https://gitea.icurfer.com/icurfer" target="_blank">Repository - Gitea</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="https://harbor.icurfer.com/" target="_blank">Registry - Harbor</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="https://argocd.icurfer.com/" target="_blank">Deploy - ArgoCD</a>
</li>
<hr>
<span>Ops</span>
{% if request.user.is_authenticated and request.user.is_staff %}
<li>
<a class="d-inline-flex align-items-center rounded" href="https://code.icurfer.com/" target="_blank">Web VScode - CodeServer</a>
</li>
{% endif %}
<li>
<a class="d-inline-flex align-items-center rounded" href="https://rancher.icurfer.com/" target="_blank">Cluster Management - Rancher</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="https://grafana.icurfer.com/" target="_blank">Monitoring - Grafana</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="https://prometheus.icurfer.com/" target="_blank">Metrics - Prometheus</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="https://os.icurfer.com/" target="_blank">Container Log - OpenSearch</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="https://kiali.icurfer.com/" target="_blank">Main Cluster Traffic - Kiali</a>
</li>
</ul>
</li>
<li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#other-collapse" aria-controls="other-collapse">Other Tools</button>
<ul class="list-unstyled ps-3 collapse" id="other-collapse"> <ul class="list-unstyled ps-3 collapse" id="other-collapse">
<li> <li>
<a class="d-inline-flex align-items-center rounded" href="https://mm.icurfer.com/" target="_blank">Messenger - Mattermost</a> <a class="d-inline-flex align-items-center rounded" href="{{request.user.url_mattermost}}" target="_blank">Messenger - Mattermost</a>
</li> </li>
</ul>
</li>
<hr>
<li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#forms-collapse" aria-controls="forms-collapse">Documents</button>
<ul class="list-unstyled ps-3 collapse" id="forms-collapse">
<li> <li>
<a class="d-inline-flex align-items-center rounded" href="#">사용가이드</a> <a class="d-inline-flex align-items-center rounded" href="{{request.user.url_nexus}}" target="_blank">Nexus</a>
</li> </li>
</ul> </ul>
</li> </li>

View File

@ -1,5 +1,5 @@
<li class="my-2"> <li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#ansible-collapse" aria-controls="ansible-collapse">On-premise Ansible</button> <button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#ansible-collapse" aria-controls="ansible-collapse">On-premise Ansible</button>
<ul class="list-unstyled ps-3 collapse" id="ansible-collapse"> <ul class="list-unstyled ps-3 collapse" id="ansible-collapse">
<li> <li>

View File

@ -0,0 +1,40 @@
<li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#components-collapse" aria-controls="components-collapse">DevOpsTools ▽</button>
<ul class="list-unstyled ps-3 collapse" id="components-collapse">
{% if request.user.is_authenticated and request.user.is_staff %}
<span>Dev</span>
<li>
<a class="d-inline-flex align-items-center rounded" href="{{request.user.url_gitea}}" target="_blank">Repository - Gitea</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="{{request.user.url_harbor}}" target="_blank">Registry - Harbor</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="{{request.user.url_argocd}}" target="_blank">Deploy - ArgoCD</a>
</li>
<hr>
<span>Ops</span>
<li>
<a class="d-inline-flex align-items-center rounded" href="{{request.user.url_web_ide}}" target="_blank">Web VScode - CodeServer</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="{{request.user.url_rancher}}" target="_blank">Cluster Management - Rancher</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="{{request.user.url_grafana}}" target="_blank">Monitoring - Grafana</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="{{request.user.url_prometheus}}" target="_blank">Metrics - Prometheus</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="{{request.user.url_opensearch}}" target="_blank">Container Log - OpenSearch</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="{{request.user.url_kiali}}" target="_blank">Main Cluster Traffic - Kiali</a>
</li>
{% else %}
<p>로그인이 필요합니다.</p>
{% endif %}
</ul>
</li>

View File

@ -0,0 +1,48 @@
<li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#nhnc-collapse" aria-controls="nhnc-collapse">NHN Cloud API ▽</button>
<ul class="list-unstyled ps-3 collapse" id="nhnc-collapse">
{% if request.user.is_authenticated and request.user.is_staff %}
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/test">출력결과 검토</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/preparations">주의 사항</a>
</li>
<hr>
<span>관리대장</span>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhnc_mgmt/igw">인터넷 게이트웨이</a>
</li>
<hr>
<span>Network</span>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/infoVpcListRequest">VPC 조회 및 삭제</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/createVpcRequest">VPC 생성</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/infoClusterListRequest">NKS Cluseter 조회 및 삭제</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/createClusterOnlyRequest">NKS Cluseter 생성</a>
</li>
<hr>
<span>k8s Components deploy</span>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/nksApply">Ingress Deploy</a>
</li>
<li>
<a class="d-inline-flex align-items-center rounded" href="/nhncloud/nksApply">FluentBit Deploy</a>
</li>
<li>
<a class="text-secondary d-inline-flex align-items-center rounded disabled" href="#">Logging Deploy(미구현)</a>
</li>
<li>
<a class="text-secondary d-inline-flex align-items-center rounded disabled" href="#">Monitoring Deploy(미구현)</a>
</li>
{% else %}
<p>로그인이 필요합니다.</p>
{% endif %}
</ul>
</li>

View File

@ -0,0 +1,8 @@
<li class="my-2">
<button class="btn d-inline-flex align-items-center collapsed" data-bs-toggle="collapse" aria-expanded="false" data-bs-target="#tm_dsbd-collapse" aria-controls="tm_dsbd-collapse">Telemetry Dashboard ▽</button>
<ul class="list-unstyled ps-3 collapse" id="tm_dsbd-collapse">
<li>
<a class="d-inline-flex align-items-center rounded" href="/tm_dsbd/grafana">grafana</a>
</li>
</ul>
</li>

View File

@ -0,0 +1,149 @@
<div class="container-fluid d-flex justify-content-between align-items-center">
<h1 class="d-flex align-items-center fs-4 text-white mb-0">
<a class="navbar-brand" href="/">
<i class="fa-solid fa-dog"></i> DDoCHI</a>
</h1>
<!-- 로그인/로그아웃 및 회원가입 버튼 -->
<ul class="navbar-nav flex-row">
{% if user.is_authenticated and user.is_superuser %}
<!-- 관리자 콘솔 버튼 -->
<li class="nav-item me-3">
<a href="/admin" target="_blank" class="btn btn-outline-warning mb-2">ADMIN</a>
</li>
{% endif %}
{% if request.user.is_authenticated %}
<!-- 회원정보 변경 버튼 -->
<li class="nav-item me-3">
<button type="button" class="btn btn-outline-info mb-2" data-bs-toggle="modal"
data-bs-target="#editProfileModal">Edit Profile</button>
</li>
<!-- 로그아웃 버튼 -->
<li class="nav-item">
<form method="post" action="{% url 'custom_auth:logout' %}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger w-100">Logout</button>
</form>
</li>
{% else %}
<!-- 로그인 버튼 -->
<li class="nav-item me-3">
<button type="button" class="btn btn-outline-primary mb-2" data-bs-toggle="modal"
data-bs-target="#loginModal">Login</button>
</li>
<!-- 회원가입 버튼 -->
<li class="nav-item me-3">
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#signupModal">Sign
Up</button>
</li>
{% endif %}
</ul>
</div>
<!-- 로그인 모달 -->
<div class="modal fade" id="loginModal" tabindex="-1" aria-labelledby="loginModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginModalLabel">Login</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- 로그인 실패 메시지 표시 -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-danger" role="alert">
{{ message }}
</div>
{% endfor %}
{% endif %}
<!-- 로그인 폼 -->
<form method="post" action="{% url 'custom_auth:login' %}">
{% csrf_token %}
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required="required">
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required="required">
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
</div>
<!-- 회원가입 모달 -->
<div class="modal fade" id="signupModal" tabindex="-1" aria-labelledby="signupModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="signupModalLabel">Sign Up</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- 회원가입 폼 -->
<form method="post" action="{% url 'custom_auth:signup' %}">
{% csrf_token %}
<div class="mb-3">
<label for="usernameSignup" class="form-label">Username</label>
<input type="text" class="form-control" id="usernameSignup" name="username" required="required">
</div>
<div class="mb-3">
<label for="passwordSignup" class="form-label">Password</label>
<input type="password" class="form-control" id="passwordSignup" name="password1" required="required">
</div>
<div class="mb-3">
<label for="passwordConfirmSignup" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="passwordConfirmSignup" name="password2" required="required">
</div>
<a href="{% url 'butler:privacy' %}" class="text-dark" target="_blank">개인정보 처리방침</a>
<p class="text-danger">회원 가입후 권한 신청 메일을 보내주세요.</p>
<p class="text-danger">회원 가입시 본 개인정보 처리방침에 동의하는 것으로 간주됩니다.</p>
<button type="submit" class="btn btn-success w-100">Sign Up</button>
</form>
</div>
</div>
</div>
</div>
<!-- 회원정보 변경 모달 -->
<div class="modal fade" id="editProfileModal" tabindex="-1" aria-labelledby="editProfileModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editProfileModalLabel">Edit Profile</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- 회원정보 변경 폼 -->
<form method="post" action="{% url 'custom_auth:edit_profile' %}">
{% csrf_token %}
<div class="mb-3">
<label for="emailEdit" class="form-label">Email</label>
<input type="email" class="form-control" id="emailEdit" name="email" value="{{ request.user.email }}" required>
</div>
<div class="mb-3">
<label for="nhncIdEdit" class="form-label">NHNC ID</label>
<input type="text" class="form-control" id="nhncIdEdit" name="nhnc_id" value="{{ request.user.nhnc_id }}">
</div>
<div class="mb-3">
<label for="nhncApiTenantIdEdit" class="form-label">NHNC API Tenant ID</label>
<input type="text" class="form-control" id="nhncApiTenantIdEdit" name="nhnc_api_tenant_id" value="{{ request.user.nhnc_api_tenant_id }}">
</div>
<div class="mb-3">
<label for="passwordEdit" class="form-label">새 비밀번호</label>
<input type="password" class="form-control" id="passwordEdit" name="password">
</div>
<div class="mb-3">
<label for="passwordConfirmEdit" class="form-label">비밀번호 확인</label>
<input type="password" class="form-control" id="passwordConfirmEdit" name="password_confirm">
</div>
<button type="submit" class="btn btn-primary w-100">Save Changes</button>
</form>
</div>
</div>
</div>
</div>

View File

@ -1,3 +1,4 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -10,6 +11,8 @@
{% include "components/_favicon.html" %} {% include "components/_favicon.html" %}
<!-- Bootstrap CSS 추가 --> <!-- Bootstrap CSS 추가 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- custom css 추가 -->
<link rel="stylesheet" href="{% static 'components/css/style.css' %}">
<!-- fontawesome 추가 --> <!-- fontawesome 추가 -->
<script src="https://kit.fontawesome.com/77d81e5d17.js" crossorigin="anonymous"></script> <script src="https://kit.fontawesome.com/77d81e5d17.js" crossorigin="anonymous"></script>
</head> </head>

View File

@ -12,14 +12,32 @@ class CustomUserAdmin(UserAdmin):
# 사용자 필드 구성 # 사용자 필드 구성
fieldsets = ( fieldsets = (
(None, {'fields': ('username', 'password')}), (None, {'fields': ('username', 'password')}),
('Personal Info', {'fields': ('email', 'encrypted_private_key')}), ('Personal Info', {
'fields': (
'email',
'encrypted_private_key',
'nhnc_id',
'nhnc_api_tenant_id',
'url_gitea',
'url_harbor',
'url_argocd',
'url_web_ide',
'url_rancher',
'url_grafana',
'url_prometheus',
'url_opensearch',
'url_kiali',
'url_nexus',
'url_mattermost',
)
}),
('Permissions', {'fields': ('is_staff', 'is_active')}), ('Permissions', {'fields': ('is_staff', 'is_active')}),
) )
# 읽기 전용 필드 추가 # 읽기 전용 필드 추가
readonly_fields = ('encrypted_private_key',) readonly_fields = ('encrypted_private_key',)
search_fields = ('username', 'email') search_fields = ('username', 'email', 'nhnc_id', 'nhnc_api_tenant_id')
ordering = ('username',) ordering = ('username',)

View File

@ -23,7 +23,23 @@ class CustomUserChangeForm(UserChangeForm):
class Meta: class Meta:
model = CustomUser model = CustomUser
fields = ["email", "nhnc_id", "nhnc_api_tenant_id"] fields = [
'email',
'nhnc_id',
'nhnc_api_tenant_id',
'url_gitea',
'url_harbor',
'url_argocd',
'url_web_ide',
'url_rancher',
'url_grafana',
'url_grafana_dashboard_01',
'url_prometheus',
'url_opensearch',
'url_kiali',
'url_nexus',
'url_mattermost',
]
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()

View File

@ -0,0 +1,58 @@
# Generated by Django 4.2.14 on 2025-01-25 00:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('custom_auth', '0005_customuser_encrypted_private_key'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='url_argocd',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='url_gitea',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='url_grafana',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='url_harbor',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='url_kiali',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='url_opensearch',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='url_prometheus',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='url_rancher',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='url_web_ide',
field=models.URLField(blank=True, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.14 on 2025-01-25 01:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('custom_auth', '0006_customuser_url_argocd_customuser_url_gitea_and_more'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='url_mattermost',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='url_nexus',
field=models.URLField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.14 on 2025-04-11 11:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('custom_auth', '0007_customuser_url_mattermost_customuser_url_nexus'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='url_grafana_dashboard_01',
field=models.URLField(blank=True, null=True),
),
]

View File

@ -5,15 +5,29 @@ from cryptography.fernet import Fernet
class CustomUser(AbstractUser): class CustomUser(AbstractUser):
"""사용자 모델 - 기존 필드 + SSH Private Key 관리 필드""" # 사용자 모델 - 기존 필드 + SSH Private Key 관리 필드
# 기존 필드 유지
grade = models.CharField(max_length=50, blank=True, null=True) grade = models.CharField(max_length=50, blank=True, null=True)
nhnc_id = models.CharField(max_length=100, blank=True, null=True) nhnc_id = models.CharField(max_length=100, blank=True, null=True)
nhnc_api_tenant_id = models.CharField(max_length=100, blank=True, null=True) nhnc_api_tenant_id = models.CharField(max_length=100, blank=True, null=True)
"""사용자 모델 - SSH Private Key 관리 필드""" # 사용자 모델 - SSH Private Key 관리 필드
encrypted_private_key = models.BinaryField(blank=True, null=True) # 암호화된 SSH 키 encrypted_private_key = models.BinaryField(blank=True, null=True) # 암호화된 SSH 키
# Custom URL 필드 - 2025-01-25
# 여기 추가하면 components/_nav.html 수정
# custom_auth/forms.py 수정, custom_auth/views.py 수정
url_gitea = models.URLField(max_length=200, blank=True, null=True)
url_harbor = models.URLField(max_length=200, blank=True, null=True)
url_argocd = models.URLField(max_length=200, blank=True, null=True)
url_web_ide = models.URLField(max_length=200, blank=True, null=True)
url_rancher = models.URLField(max_length=200, blank=True, null=True)
url_grafana = models.URLField(max_length=200, blank=True, null=True)
url_grafana_dashboard_01 = models.URLField(max_length=200, blank=True, null=True) # 2025-04-11 추가
url_prometheus = models.URLField(max_length=200, blank=True, null=True)
url_opensearch = models.URLField(max_length=200, blank=True, null=True)
url_kiali = models.URLField(max_length=200, blank=True, null=True)
url_nexus = models.URLField(max_length=200, blank=True, null=True)
url_mattermost = models.URLField(max_length=200, blank=True, null=True)
def encrypt_private_key(self, private_key: str) -> bytes: def encrypt_private_key(self, private_key: str) -> bytes:
"""SSH Private Key 암호화""" """SSH Private Key 암호화"""

0
obs_minio/__init__.py Normal file
View File

3
obs_minio/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
obs_minio/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ObsMinioConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'obs_minio'

View File

3
obs_minio/models.py Normal file
View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

3
obs_minio/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
obs_minio/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = 'obs_minio'
urlpatterns = [
path('upload/', views.upload_image, name='upload_image'),
path('get_presigned_url/', views.get_presigned_url, name='get_presigned_url'),
]

76
obs_minio/views.py Normal file
View File

@ -0,0 +1,76 @@
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from minio import Minio
from minio.error import S3Error
import json
import uuid
import urllib3
# MinIO 설정
MINIO_ENDPOINT = settings.MINIO_ENDPOINT_URL
MINIO_ACCESS_KEY = settings.MINIO_ACCESS_KEY
MINIO_SECRET_KEY = settings.MINIO_SECRET_KEY
BUCKET_NAME = settings.MINIO_DEFAULT_BUCKET
# MinIO 클라이언트 생성
client = Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=True,
http_client=urllib3.PoolManager(cert_reqs='CERT_NONE') # SSL 검증 비활성화
)
@csrf_exempt
def upload_image(request):
"""
이미지 업로드 및 URL 반환
"""
if request.method == 'POST' and 'image' in request.FILES:
image = request.FILES['image']
unique_filename = f"uploads/{uuid.uuid4()}_{image.name}"
try:
# MinIO에 파일 업로드
client.put_object(
bucket_name=BUCKET_NAME,
object_name=unique_filename,
data=image,
length=image.size,
content_type=image.content_type,
)
# 전체 URL 생성
full_url = f"https://{MINIO_ENDPOINT}/{BUCKET_NAME}/{unique_filename}"
return JsonResponse({"url": full_url}, status=201)
except S3Error as e:
return JsonResponse({"error": str(e)}, status=500)
return JsonResponse({"error": "Invalid request"}, status=400)
@csrf_exempt
def get_presigned_url(request):
"""
Presigned URL 생성 및 반환
"""
if request.method == 'POST':
try:
data = json.loads(request.body)
object_name = data.get('object_name')
if not object_name:
return JsonResponse({"error": "Missing object_name"}, status=400)
# Presigned URL 생성
presigned_url = client.presigned_get_object(BUCKET_NAME, object_name)
return JsonResponse({"presigned_url": presigned_url}, status=200)
except S3Error as e:
return JsonResponse({"error": str(e)}, status=500)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
return JsonResponse({"error": "Invalid request method"}, status=405)

View File

@ -9,6 +9,7 @@ certifi==2024.7.4
cffi==1.16.0 cffi==1.16.0
charset-normalizer==3.3.2 charset-normalizer==3.3.2
cryptography==43.0.0 cryptography==43.0.0
Deprecated==1.2.18
dj-rest-auth==6.0.0 dj-rest-auth==6.0.0
Django==4.2.14 Django==4.2.14
django-allauth==0.63.6 django-allauth==0.63.6
@ -18,6 +19,8 @@ django-taggit==6.0.0
djangorestframework==3.15.2 djangorestframework==3.15.2
durationpy==0.9 durationpy==0.9
google-auth==2.35.0 google-auth==2.35.0
googleapis-common-protos==1.69.2
grpcio==1.71.0
gunicorn==22.0.0 gunicorn==22.0.0
idna==3.7 idna==3.7
importlib_metadata==8.4.0 importlib_metadata==8.4.0
@ -31,8 +34,21 @@ mdurl==0.1.2
minio==7.2.7 minio==7.2.7
mysqlclient==2.2.4 mysqlclient==2.2.4
oauthlib==3.2.2 oauthlib==3.2.2
opentelemetry-api==1.32.0
opentelemetry-exporter-otlp==1.32.0
opentelemetry-exporter-otlp-proto-common==1.32.0
opentelemetry-exporter-otlp-proto-grpc==1.32.0
opentelemetry-exporter-otlp-proto-http==1.32.0
opentelemetry-instrumentation==0.53b0
opentelemetry-instrumentation-django==0.53b0
opentelemetry-instrumentation-wsgi==0.53b0
opentelemetry-proto==1.32.0
opentelemetry-sdk==1.32.0
opentelemetry-semantic-conventions==0.53b0
opentelemetry-util-http==0.53b0
packaging==24.1 packaging==24.1
pillow==10.4.0 pillow==10.4.0
protobuf==5.29.4
pyasn1==0.6.1 pyasn1==0.6.1
pyasn1_modules==0.4.1 pyasn1_modules==0.4.1
pycparser==2.22 pycparser==2.22
@ -52,4 +68,5 @@ tzlocal==5.2
urllib3==2.2.2 urllib3==2.2.2
websocket-client==1.8.0 websocket-client==1.8.0
whitenoise==6.7.0 whitenoise==6.7.0
wrapt==1.17.2
zipp==3.20.0 zipp==3.20.0

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TelemetryDashboardConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'telemetry_dashboard'

View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@ -0,0 +1,17 @@
{% extends "components/base.html" %}
{% block title %}Landing Page{% endblock %}
{% block main_area %}
<article class="pt-3">
<h2 class="fw-bold pb-2">Grafana</h2>
<!-- Grafana IFrame -->
<div class="ratio ratio-16x9">
{% if request.user.url_grafana_dashboard_01 %}
<iframe src="{{request.user.url_grafana_dashboard_01}}" width="100%" height="800" frameborder="0" allowfullscreen></iframe>
{% else %}
<h3 class="text-danger">접속 후 Profile에 Dashboard URL을 등록하세요.</h3>
{% endif %}
</div>
</article>
{% endblock %}

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,8 @@
from django.urls import path
from . import views
app_name = 'tm_dsbd'
urlpatterns = [
path('grafana/', views.grafana_view, name='grafana'),
]

View File

@ -0,0 +1,11 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.views.generic import TemplateView
# from django.db.models import Q
def grafana_view(request):
return render(
request,
"telemetry_dashboard/grafana.html",
)

View File

@ -1 +1 @@
dev_0.0.22 dev_0.0.44