This commit is contained in:
Tomas Dvorak
2026-02-23 16:43:39 +01:00
parent b62cf649d9
commit 0977d95539
301 changed files with 52067 additions and 3801 deletions
Submodule .claude/skills/desloppify added at c59f2a07ed
+696
View File
@@ -0,0 +1,696 @@
{
"command": "scan",
"overall_score": 81.0,
"objective_score": 82.8,
"strict_score": 73.3,
"strict_target_score": 95.0,
"strict_target_gap": 21.7,
"prev_overall_score": 81.0,
"prev_objective_score": 82.8,
"prev_strict_score": 73.3,
"delta_comparable": true,
"delta_comparable_reason": null,
"profile": "full",
"noise_budget": 10,
"noise_global_budget": 0,
"hidden_by_detector": {
"subjective_review": 58,
"test_coverage": 29
},
"hidden_total": 87,
"diff": {
"new": 0,
"auto_resolved": 0,
"reopened": 0,
"total_current": 235,
"suspect_detectors": [],
"chronic_reopeners": [],
"skipped_other_lang": 0,
"skipped_out_of_scope": 0,
"ignored": 0,
"ignore_patterns": 0,
"ignored_by_detector": {},
"ignored_by_tier": {},
"raw_findings": 235,
"suppressed_pct": 0.0
},
"stats": {
"total": 599,
"open": 109,
"fixed": 110,
"auto_resolved": 254,
"wontfix": 126,
"false_positive": 0,
"by_tier": {
"1": {
"open": 1,
"fixed": 61,
"auto_resolved": 3,
"wontfix": 0,
"false_positive": 0
},
"2": {
"open": 0,
"fixed": 49,
"auto_resolved": 88,
"wontfix": 18,
"false_positive": 0
},
"3": {
"open": 40,
"fixed": 0,
"auto_resolved": 138,
"wontfix": 108,
"false_positive": 0
},
"4": {
"open": 68,
"fixed": 0,
"auto_resolved": 25,
"wontfix": 0,
"false_positive": 0
}
}
},
"warnings": [],
"dimension_scores": {
"File health": {
"score": 100.0,
"strict": 83.1,
"checks": 83,
"issues": 0,
"tier": 3,
"detectors": {
"structural": {
"potential": 83,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
}
}
},
"Code quality": {
"score": 100.0,
"strict": 92.2,
"checks": 574,
"issues": 0,
"tier": 3,
"detectors": {
"unused": {
"potential": 83,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"logs": {
"potential": 83,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"exports": {
"potential": 135,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"props": {
"potential": 53,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"smells": {
"potential": 83,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"react": {
"potential": 11,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"orphaned": {
"potential": 37,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"flat_dirs": {
"potential": 24,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"naming": {
"potential": 24,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"facade": {
"potential": 37,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"patterns": {
"potential": 4,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
}
}
},
"Duplication": {
"score": 100.0,
"strict": 99.8,
"checks": 130,
"issues": 0,
"tier": 3,
"detectors": {
"dupes": {
"potential": 130,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
}
}
},
"Test health": {
"score": 45.9,
"strict": 24.7,
"checks": 1122,
"issues": 107,
"tier": 4,
"detectors": {
"test_coverage": {
"potential": 1044,
"pass_rate": 0.4384041957357197,
"issues": 39,
"weighted_failures": 586.3060196519086
},
"subjective_review": {
"potential": 78,
"pass_rate": 0.7384615384615385,
"issues": 68,
"weighted_failures": 20.4
}
}
},
"Security": {
"score": 100.0,
"strict": 100.0,
"checks": 120,
"issues": 0,
"tier": 4,
"detectors": {
"security": {
"potential": 83,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
},
"cycles": {
"potential": 37,
"pass_rate": 1.0,
"issues": 0,
"weighted_failures": 0.0
}
}
},
"Naming Quality": {
"score": 82.0,
"strict": 82.0,
"checks": 10,
"issues": 0,
"tier": 4,
"detectors": {
"subjective_assessment": {
"potential": 10,
"pass_rate": 0.82,
"issues": 0,
"weighted_failures": 1.8
}
}
},
"Error Consistency": {
"score": 68.0,
"strict": 68.0,
"checks": 10,
"issues": 0,
"tier": 4,
"detectors": {
"subjective_assessment": {
"potential": 10,
"pass_rate": 0.68,
"issues": 0,
"weighted_failures": 3.2
}
}
},
"Abstraction Fit": {
"score": 75.0,
"strict": 75.0,
"checks": 10,
"issues": 0,
"tier": 4,
"detectors": {
"subjective_assessment": {
"potential": 10,
"pass_rate": 0.75,
"issues": 0,
"weighted_failures": 2.5
}
}
},
"Logic Clarity": {
"score": 88.0,
"strict": 88.0,
"checks": 10,
"issues": 0,
"tier": 4,
"detectors": {
"subjective_assessment": {
"potential": 10,
"pass_rate": 0.88,
"issues": 0,
"weighted_failures": 1.2
}
}
},
"AI Generated Debt": {
"score": 72.0,
"strict": 72.0,
"checks": 10,
"issues": 0,
"tier": 4,
"detectors": {
"subjective_assessment": {
"potential": 10,
"pass_rate": 0.72,
"issues": 0,
"weighted_failures": 2.8
}
}
},
"Type Safety": {
"score": 70.0,
"strict": 70.0,
"checks": 10,
"issues": 0,
"tier": 4,
"detectors": {
"subjective_assessment": {
"potential": 10,
"pass_rate": 0.7,
"issues": 0,
"weighted_failures": 3.0
}
}
},
"Contract Coherence": {
"score": 74.0,
"strict": 74.0,
"checks": 10,
"issues": 0,
"tier": 4,
"detectors": {
"subjective_assessment": {
"potential": 10,
"pass_rate": 0.74,
"issues": 0,
"weighted_failures": 2.6
}
}
}
},
"potentials": {
"typescript": {
"logs": 83,
"unused": 83,
"exports": 135,
"deprecated": 0,
"structural": 83,
"flat_dirs": 24,
"props": 53,
"single_use": 0,
"coupling": 0,
"cycles": 37,
"orphaned": 37,
"patterns": 4,
"naming": 24,
"facade": 37,
"test_coverage": 1044,
"smells": 83,
"react": 11,
"security": 83,
"subjective_review": 78,
"dupes": 130
}
},
"zone_distribution": {
"generated": 84,
"test": 27,
"production": 83,
"config": 2
},
"narrative": {
"phase": "middle_grind",
"headline": "109 findings open. Test health (24.7%) needs attention \u2014 run `desloppify next` to start.",
"dimensions": {
"lowest_dimensions": [
{
"name": "Test health",
"strict": 24.7,
"issues": 107,
"impact": 0.0
},
{
"name": "Error Consistency",
"strict": 68.0,
"issues": 0,
"impact": 0.0,
"subjective": true,
"impact_description": "re-review to improve"
},
{
"name": "Type Safety",
"strict": 70.0,
"issues": 0,
"impact": 0.0,
"subjective": true,
"impact_description": "re-review to improve"
}
],
"biggest_gap_dimensions": [
{
"name": "Test health",
"lenient": 45.9,
"strict": 24.7,
"gap": 21.2,
"wontfix_count": 16
},
{
"name": "File health",
"lenient": 100.0,
"strict": 83.1,
"gap": 16.9,
"wontfix_count": 20
},
{
"name": "Code quality",
"lenient": 100.0,
"strict": 92.2,
"gap": 7.8,
"wontfix_count": 89
}
],
"stagnant_dimensions": [
{
"name": "File health",
"strict": 83.1,
"stuck_scans": 5
},
{
"name": "Code quality",
"strict": 92.2,
"stuck_scans": 5
},
{
"name": "Duplication",
"strict": 99.8,
"stuck_scans": 5
},
{
"name": "Security",
"strict": 100.0,
"stuck_scans": 5
},
{
"name": "Naming Quality",
"strict": 82.0,
"stuck_scans": 5
},
{
"name": "Error Consistency",
"strict": 68.0,
"stuck_scans": 5
},
{
"name": "Abstraction Fit",
"strict": 75.0,
"stuck_scans": 5
},
{
"name": "Logic Clarity",
"strict": 88.0,
"stuck_scans": 5
},
{
"name": "AI Generated Debt",
"strict": 72.0,
"stuck_scans": 5
},
{
"name": "Type Safety",
"strict": 70.0,
"stuck_scans": 5
},
{
"name": "Contract Coherence",
"strict": 74.0,
"stuck_scans": 5
}
]
},
"actions": [
{
"priority": 1,
"type": "auto_fix",
"detector": "unused",
"count": 1,
"description": "1 unused findings \u2014 run `desloppify fix unused-imports --dry-run` to preview, then apply",
"command": "desloppify fix unused-imports --dry-run",
"impact": 0.0,
"dimension": "Code quality",
"lane": "cleanup"
},
{
"priority": 2,
"type": "auto_fix",
"detector": "smells",
"count": 1,
"description": "1 smells findings \u2014 run `desloppify fix dead-useeffect --dry-run` to preview, then apply",
"command": "desloppify fix dead-useeffect --dry-run",
"impact": 0.0,
"dimension": "Code quality",
"lane": "cleanup"
},
{
"priority": 3,
"type": "refactor",
"detector": "test_coverage",
"count": 39,
"description": "39 test_coverage findings \u2014 add tests for untested production modules \u2014 prioritize by import count",
"command": "desloppify show test_coverage --status open",
"impact": 0.0,
"dimension": "Test health",
"lane": "test_coverage"
},
{
"priority": 4,
"type": "manual_fix",
"detector": "subjective_review",
"count": 68,
"description": "68 files need design review \u2014 run `desloppify review --prepare` to generate review context",
"command": "desloppify review --prepare",
"impact": 0.0,
"dimension": "Test health",
"lane": "refactor"
},
{
"priority": 5,
"type": "debt_review",
"detector": null,
"description": "3.6 pts of wontfix debt \u2014 review stale decisions",
"command": "desloppify show --status wontfix",
"gap": 3.6,
"lane": "debt_review"
}
],
"strategy": {
"fixer_leverage": {
"auto_fixable_count": 2,
"total_count": 109,
"coverage": 0.018,
"impact_ratio": 0.0,
"recommendation": "none"
},
"lanes": {
"cleanup": {
"actions": [
2,
1
],
"file_count": 2,
"total_impact": 0.0,
"automation": "full",
"run_first": false
},
"refactor": {
"actions": [
4
],
"file_count": 68,
"total_impact": 0.0,
"automation": "manual",
"run_first": false
},
"test_coverage": {
"actions": [
3
],
"file_count": 39,
"total_impact": 0.0,
"automation": "manual",
"run_first": false
},
"debt_review": {
"actions": [
5
],
"file_count": 0,
"total_impact": 0.0,
"automation": "manual",
"run_first": false
}
},
"can_parallelize": true,
"hint": "3 independent workstreams, safe to parallelize. Rescan after each phase to verify."
},
"tools": {
"fixers": [
{
"name": "unused-imports",
"detector": "unused",
"open_count": 1,
"command": "desloppify fix unused-imports --dry-run"
},
{
"name": "unused-vars",
"detector": "unused",
"open_count": 1,
"command": "desloppify fix unused-vars --dry-run"
},
{
"name": "unused-params",
"detector": "unused",
"open_count": 1,
"command": "desloppify fix unused-params --dry-run"
},
{
"name": "dead-useeffect",
"detector": "smells",
"open_count": 1,
"command": "desloppify fix dead-useeffect --dry-run"
},
{
"name": "empty-if-chain",
"detector": "smells",
"open_count": 1,
"command": "desloppify fix empty-if-chain --dry-run"
}
],
"move": {
"available": true,
"relevant": false,
"reason": null,
"usage": "desloppify move <source> <dest> [--dry-run]"
},
"plan": {
"command": "desloppify plan",
"description": "Generate prioritized markdown cleanup plan"
},
"badge": {
"generated": true,
"in_readme": true,
"path": "scorecard.png",
"recommendation": null
}
},
"debt": {
"overall_gap": 3.6,
"wontfix_count": 126,
"worst_dimension": "Test health",
"worst_gap": 21.2,
"trend": "growing"
},
"milestone": null,
"primary_action": {
"priority": 1,
"type": "auto_fix",
"detector": "unused",
"command": "desloppify fix unused-imports --dry-run",
"description": "1 unused findings \u2014 run `desloppify fix unused-imports --dry-run` to preview, then apply",
"impact": 0.0,
"lane": "cleanup",
"count": 1
},
"why_now": "`unused` is currently the highest-priority unresolved workstream.",
"verification_step": {
"command": "desloppify scan",
"reason": "Re-scan to verify the change and catch cascading findings before reporting progress.",
"success_signal": "Open findings drop and strict score direction remains positive."
},
"risk_flags": [],
"strict_target": {
"target": 95.0,
"current": 73.3,
"gap": 21.7,
"state": "below",
"warning": null
},
"reminders": [
{
"type": "report_scores",
"message": "ALWAYS share ALL scores with the user: overall, objective, and strict, plus every dimension score (lenient + strict), including subjective dimensions. The goal is to maximize strict scores.",
"command": null,
"severity": "critical",
"priority": 0,
"stage": "reporting",
"no_decay": true
}
],
"reminder_history": {
"report_scores": 59,
"auto_fixers_available": 3,
"dry_run_first": 3,
"zone_classification": 3,
"feedback_nudge": 3,
"stagnant_nudge": 11,
"wontfix_growing": 3
}
},
"config": {
"review_max_age_days": 30,
"holistic_max_age_days": 30,
"generate_scorecard": true,
"badge_path": "scorecard.png",
"exclude": [],
"ignore": [],
"ignore_metadata": {},
"zone_overrides": {},
"review_dimensions": [],
"review_allow_custom_dimensions": false,
"review_custom_dimensions": [],
"large_files_threshold": 0,
"props_threshold": 0,
"finding_noise_budget": 10,
"finding_noise_global_budget": 0,
"target_strict_score": 95,
"languages": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="./canvas_inspirational.png" alt="Containr Logo" width="200"> <img src="./containr.svg" alt="Containr Logo" width="200">
</p> </p>
<h1 align="center"> <h1 align="center">
@@ -24,6 +24,10 @@
<a href="#contributing">Contributing</a> <a href="#contributing">Contributing</a>
</p> </p>
<p align="center">
<img src="./scorecard.png" alt="Code Quality Scorecard" width="100%">
</p>
> **⚠️ Development Status**: This project is currently under active development and is **not yet ready for production use**. The current codebase represents the default structure and foundation for the container management platform. Many features are still being implemented and may not work as expected. > **⚠️ Development Status**: This project is currently under active development and is **not yet ready for production use**. The current codebase represents the default structure and foundation for the container management platform. Many features are still being implemented and may not work as expected.
> **🚀 Inspired by Railway**: This project draws inspiration from [Railway](https://railway.app)'s seamless deployment experience and developer-friendly approach. The goal is to bring that same level of simplicity and power to self-hosted container management. > **🚀 Inspired by Railway**: This project draws inspiration from [Railway](https://railway.app)'s seamless deployment experience and developer-friendly approach. The goal is to bring that same level of simplicity and power to self-hosted container management.
+172
View File
@@ -0,0 +1,172 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<defs>
<style>
.st0 {
fill: #877cd6;
}
.st1 {
fill: #6e65d8;
}
.st2 {
fill: url(#linear-gradient2);
}
.st3 {
fill: #6e63d7;
}
.st4 {
fill: #b1abf0;
}
.st5 {
fill: url(#linear-gradient1);
}
.st6 {
fill: #a39ae9;
}
.st7 {
fill: #8476d5;
}
.st8 {
fill: #aaa2eb;
}
.st9 {
fill: #675bd2;
}
.st10 {
fill: #b9b4f1;
}
.st11 {
fill: #5449b9;
}
.st12 {
fill: #655acd;
}
.st13 {
fill: #b6b1f0;
}
.st14 {
fill: #9e93eb;
}
.st15 {
fill: #a298e8;
}
.st16 {
fill: #4239a5;
}
.st17 {
fill: #b0a9ed;
}
.st18 {
fill: #3d35a1;
}
.st19 {
fill: #8f80e4;
}
.st20 {
fill: #9186f3;
}
.st21 {
fill: #5a4ec2;
}
.st22 {
fill: #423aa4;
}
.st23 {
fill: #8e81dc;
}
.st24 {
fill: url(#linear-gradient);
}
.st25 {
fill: #594ec0;
}
.st26 {
fill: #9487e7;
}
</style>
<linearGradient id="linear-gradient" x1="945.85" y1="1577.02" x2="761.14" y2="1748.14" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7367dc"/>
<stop offset="1" stop-color="#4e44b1"/>
</linearGradient>
<linearGradient id="linear-gradient1" x1="722.68" y1="1595.53" x2="596.26" y2="1731.01" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#5b53c3"/>
<stop offset="1" stop-color="#392e91"/>
</linearGradient>
<linearGradient id="linear-gradient2" x1="948.55" y1="1362.8" x2="635.29" y2="1790.01" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#887ded"/>
<stop offset="1" stop-color="#342a89"/>
</linearGradient>
</defs>
<polygon class="st20" points="681.31 447.54 681.31 467.16 481.77 437.1 483.48 417.01 681.31 447.54"/>
<path class="st18" d="M555.67,521.86c4.67-.95,15.46,7.01,23.68,7.48,10.63.6,19.08-6.88,26.17-5.47,5.58,1.11,5.41,7.21,2.27,10.95-11.39,13.58-43.92,11.42-55.13-1.69-3.59-4.2-2.96-10.06,3-11.27Z"/>
<circle class="st16" cx="528.3" cy="506.02" r="13.52"/>
<circle class="st22" cx="632.44" cy="508.64" r="13.43"/>
<g>
<path class="st24" d="M712.92,596.88v110.09l-234.36,18.53c-1.16-49.03-1.16-98.08,0-147.15.23-.82-.31-2.2.05-3.19,21.9,1.33,43.96,3.31,65.89,5.38,7.24.68,14.5,1.49,21.8,2.18,48.86,4.63,97.74,9.75,146.62,14.16Z"/>
<g>
<path class="st3" d="M712.92,706.97v-110.09,110.09Z"/>
<path class="st4" d="M684.58,612.14c1.03,24.3,1.03,48.64,0,73.03-.26,1.25.34,2.95-.05,4.3-7.86,1.68-15.92,1.09-23.93,1.16v-81.75c4.86,1.32,21.04.41,23.98,3.27Z"/>
<path class="st17" d="M531.97,596.88v68.67c-.15.54-.33,1.07-.6,1.55-1.78,3.15-25.19-1.98-24.47-9.18v-62.13c2.91-2.93,19.92,2.34,25.07,1.09Z"/>
<path class="st13" d="M583.21,602.33c1.01,21.29,1.14,42.65.39,64.08l-1.56.27c-6.01-4.73-13.02-8.71-20.97-7.04l-1.85-1.71v-57.77c.85-.85,22.56,1.51,23.98,2.18Z"/>
<path class="st10" d="M634.44,606.69v52.32l-2.45.71c-5.32-4.68-12.12-9.52-19.58-7.78l-1.95-1.65v-45.78l23.98,2.18Z"/>
<path class="st8" d="M634.44,659.01v32.7l-23.98,1.09v-42.51c8.78-3.35,17.83,2.97,23.98,8.72Z"/>
<path class="st6" d="M572.31,694.98l-13.63.35.55-37.41c7.76-2.78,18.95,1.52,23.97,7.63v-63.22c4.08,1.92-.83,79.47,1.1,89.93-.76,4.34-8.2,2.64-11.99,2.72Z"/>
<path class="st15" d="M531.97,665.55c.59,10.18.59,20.36,0,30.52-.66,2.02-12.84.95-15.85,1.04-2.38.07-8.37,2.53-9.22.05v-39.24c5.92,5.49,17.26,8.87,25.07,7.63Z"/>
<path class="st12" d="M559.23,657.92c.05,12.34-.04,24.73,0,37.07,4.35-.07,8.74.09,13.09,0l-14.18,1.1,1.1-95.93c.06,19.24-.08,38.53,0,57.77Z"/>
<path class="st12" d="M506.9,657.92c.05,13.06-.04,26.18,0,39.24v-101.37c.06,20.69-.08,41.44,0,62.13Z"/>
<path class="st12" d="M531.97,696.07c1.19-3.63-.03-24.65,0-30.52.14-22.87-.1-45.8,0-68.67v99.19Z"/>
<path class="st3" d="M610.46,650.29c.05,14.15-.04,28.36,0,42.51v-88.29c.04,15.24-.05,30.54,0,45.78Z"/>
<path class="st3" d="M634.44,691.71c.02-10.88-.03-21.82,0-32.7.05-17.42-.03-34.9,0-52.32v85.02Z"/>
<path class="st3" d="M684.58,685.17v-73.03c.86.84,1.32,1.19,1.15,2.61-2.14,21.8,3.15,49.6-1.15,70.42Z"/>
</g>
</g>
<g>
<polygon class="st5" points="453.49 725.5 306.33 705.88 306.28 601.87 453.49 573.99 453.49 725.5"/>
<g>
<path class="st23" d="M408.8,608.87c2.53-2.96,13.34-2.52,17.43-4.36.24,7.3.9,14.48,1.1,21.8l-.66,68.12-8.06-.54c-2.16.07-4.37.04-6.54,0l-3.11-.93c-.93-27.55-1.62-56.31-.16-84.09Z"/>
<path class="st0" d="M387,611.05v80.66c-3.62-2.19-15.34.22-16.96-2.11l-.33-74.59c.96-2.88,13.95-2.4,17.29-3.96Z"/>
<polygon class="st7" points="348.85 617.59 348.85 688.44 332.5 687.35 332.5 619.77 348.85 617.59"/>
<path class="st11" d="M408.8,608.87l-.09,82.37,3.36,2.65c-1.45-.03-2.92.04-4.37,0,1.53-10.81-2.44-80.9,1.1-85.03Z"/>
<path class="st11" d="M427.33,626.31c.63,22.84-.47,45.83,0,68.68l-8.73-1.09c2.53-.08,5.1.06,7.64,0l1.08-67.59Z"/>
</g>
</g>
<g>
<path class="st2" d="M336.86,517.31c.08-12.89-1.57-31.04-.06-43.12.15-1.2-.08-2.43,1.14-3.22l129.72-55.03,1.08,123.49,39.25,13.84c-16.1,1.65-32.37,1.74-48.49,3.29-54.98,5.3-109.8,17.26-163.87,27.97-2.52-.29-5.64,5.91-5.64,7.44v106.28c-44.32-.62-86.36-25.78-106.3-65.38-37.11-73.67,8.88-159.45,89.48-171.08,5.05-70.54,77.25-114.81,143.6-93.75,29.07-38.59,75.03-64,123.39-68.73,87.07-8.53,171.01,51.08,190.76,136.23,1.53,6.58,5.31,22.98,4.99,28.91-.31,5.95-8.43,6.69-11,11.42,76.86-14.03,142.15,54.55,125.94,130.85-11.63,54.71-59.12,92.08-115.04,91.52l-1.09-119.9-68.67-12c.38-2.1,2.28-1.8,3.76-2.22,3.68-1.06,27.53-4.67,27.91-6.07l-.04-116.53c-.1-3.45-2.21-6.47-5.46-7.62l-217.45-34.36c-50.26,17.71-99.5,38.5-149.08,58.09-3.38,1.55-4.24,4.11-5.18,7.35-1.1,30.52-1.1,61.04,0,91.56,1,1.09.36,1.02,2.17.66,1.57-.31,12.26-3.46,13.09-3.93,1.12-11.83,1.48-23.82,1.09-35.97Z"/>
<g>
<path class="st1" d="M735.81,698.25l-1.09-119.9,1.09,119.9Z"/>
<path class="st25" d="M320.51,556.55c-.76-.83-1.38-1.13-1.15-2.62,2.09-27.37-2.57-58.33-.02-85.26.21-2.19.83-2.53,1.17-3.69v91.56Z"/>
<path class="st14" d="M441.5,456.26v85.02c-2.84,1.69-12.57,1.65-16.35,2.18v-81.21c3.7-2.31,9.24-5.16,13.35-6.37,1.09-.32,2.59-1.31,3,.37Z"/>
<path class="st26" d="M404.44,470.43v76.3c-5.3,1.35-10.21.87-15.26,3.27v-75.21c5.46-.27,9.78-4.76,15.26-4.36Z"/>
<path class="st19" d="M370.65,552.19c-3.11,1.45-9.5.64-13.08,2.18v-66.49c0-.61,10.85-4.28,11.46-4.36,2.82-.39,1.19,1.72,1.62,3.27,1.04,21.85,1.04,43.65,0,65.4Z"/>
<path class="st9" d="M441.5,541.29v-85.02,85.02Z"/>
<path class="st21" d="M370.65,552.19v-65.4c4.6,16.83-1.22,44.53,1.15,62.76-.16,1.29.36,1.94-1.15,2.64Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

+7 -3
View File
@@ -33,7 +33,7 @@ services:
- "traefik.http.routers.traefik.tls.certresolver=myresolver" - "traefik.http.routers.traefik.tls.certresolver=myresolver"
- "traefik.http.routers.traefik.service=api@internal" - "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=traefik-auth" - "traefik.http.routers.traefik.middlewares=traefik-auth"
- "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_AUTH:-admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/}" - "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_AUTH}"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "traefik", "ping"] test: ["CMD", "traefik", "ping"]
@@ -43,7 +43,7 @@ services:
# PostgreSQL Database # PostgreSQL Database
postgres: postgres:
image: postgres:16-alpine image: postgres:15-alpine
container_name: containr-postgres container_name: containr-postgres
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-containr} POSTGRES_DB: ${POSTGRES_DB:-containr}
@@ -83,6 +83,8 @@ services:
context: . context: .
dockerfile: Dockerfile.backend dockerfile: Dockerfile.backend
container_name: containr-backend container_name: containr-backend
ports:
- "8081:8080" # Temporary direct access
environment: environment:
- DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable - DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
- REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379 - REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379
@@ -91,7 +93,7 @@ services:
- JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_key_change_in_production} - JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_key_change_in_production}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost,http://localhost:3000} - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost,http://localhost:3000}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro # For Docker API access # /var/run/docker.sock:/var/run/docker.sock:ro # Commented out to prevent permission issues
# Development volumes (only mounted in dev mode) # Development volumes (only mounted in dev mode)
- ${DEV_MODE:-./internal}:/app/internal - ${DEV_MODE:-./internal}:/app/internal
networks: networks:
@@ -120,6 +122,8 @@ services:
context: . context: .
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
container_name: containr-frontend container_name: containr-frontend
ports:
- "3000:80" # Temporary direct access
environment: environment:
- VITE_API_URL=${VITE_API_URL:-http://api.localhost} - VITE_API_URL=${VITE_API_URL:-http://api.localhost}
- VITE_ENVIRONMENT=${ENVIRONMENT:-production} - VITE_ENVIRONMENT=${ENVIRONMENT:-production}
+1
View File
@@ -36,6 +36,7 @@ require (
github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+2
View File
@@ -70,6 +70,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+177
View File
@@ -0,0 +1,177 @@
package api
import (
"containr/internal/database"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AuditLog struct {
ID string `json:"id" db:"id"`
UserID string `json:"user_id" db:"user_id"`
UserEmail string `json:"user_email" db:"user_email"`
Resource string `json:"resource" db:"resource"`
ResourceID string `json:"resource_id" db:"resource_id"`
Action string `json:"action" db:"action"`
Details string `json:"details" db:"details"`
IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type AuditLogDetail struct {
OldValue interface{} `json:"old_value,omitempty"`
NewValue interface{} `json:"new_value,omitempty"`
Message string `json:"message,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
func LogAudit(userID, resource, resourceID, action string, details map[string]interface{}) {
db := GetAuditDB()
if db == nil {
return
}
detailsJSON, _ := json.Marshal(details)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
auditID, userID, resource, resourceID, action, string(detailsJSON), time.Now(),
)
if err != nil {
}
}
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
userID, _ := c.Get("user_id")
userEmail, _ := c.Get("user_email")
details["ip_address"] = c.ClientIP()
details["user_agent"] = c.GetHeader("User-Agent")
detailsJSON, _ := json.Marshal(details)
db := c.MustGet("db").(*database.DB)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, user_email, resource, resource_id, action, details, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
auditID, userID, userEmail, resource, resourceID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), time.Now(),
)
if err != nil {
}
}
var auditDB *database.DB
func GetAuditDB() *database.DB {
return auditDB
}
func SetAuditDB(db *database.DB) {
auditDB = db
}
func handleGetAuditLogs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
resource := c.Query("resource")
action := c.Query("action")
page := c.DefaultQuery("page", "1")
limit := c.DefaultQuery("limit", "50")
query := `SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
FROM audit_logs WHERE user_id = $1`
args := []interface{}{userID}
argNum := 2
if resource != "" {
query += " AND resource = $" + string(rune('0'+argNum))
args = append(args, resource)
argNum++
}
if action != "" {
query += " AND action = $" + string(rune('0'+argNum))
args = append(args, action)
argNum++
}
query += " ORDER BY created_at DESC LIMIT $" + string(rune('0'+argNum)) + " OFFSET $" + string(rune('0'+argNum+1))
args = append(args, limit, (atoi(page)-1)*atoi(limit))
rows, err := db.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
return
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
var log AuditLog
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
if err != nil {
continue
}
logs = append(logs, log)
}
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
}
func handleGetResourceAuditLogs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
resource := c.Param("resource")
resourceID := c.Param("id")
rows, err := db.Query(
`SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
FROM audit_logs
WHERE user_id = $1 AND resource = $2 AND resource_id = $3
ORDER BY created_at DESC
LIMIT 100`,
userID, resource, resourceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
return
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
var log AuditLog
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
if err != nil {
continue
}
logs = append(logs, log)
}
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
}
func atoi(s string) int {
var result int
for _, c := range s {
if c >= '0' && c <= '9' {
result = result*10 + int(c-'0')
}
}
return result
}
+416
View File
@@ -0,0 +1,416 @@
package api
import (
"containr/internal/database"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type CronJob struct {
ID string `json:"id" db:"id"`
ProjectID string `json:"project_id" db:"project_id"`
ServiceID string `json:"service_id" db:"service_id"`
Name string `json:"name" db:"name"`
Schedule string `json:"schedule" db:"schedule"`
Command string `json:"command" db:"command"`
Timezone string `json:"timezone" db:"timezone"`
Enabled bool `json:"enabled" db:"enabled"`
LastRunAt *time.Time `json:"last_run_at" db:"last_run_at"`
NextRunAt *time.Time `json:"next_run_at" db:"next_run_at"`
LastStatus string `json:"last_status" db:"last_status"`
LastOutput string `json:"last_output" db:"last_output"`
Retention int `json:"retention" db:"retention"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CronExecution struct {
ID string `json:"id" db:"id"`
CronJobID string `json:"cron_job_id" db:"cron_job_id"`
StartedAt time.Time `json:"started_at" db:"started_at"`
FinishedAt *time.Time `json:"finished_at" db:"finished_at"`
Status string `json:"status" db:"status"`
Output string `json:"output" db:"output"`
Error string `json:"error" db:"error"`
}
type CreateCronJobRequest struct {
ProjectID string `json:"project_id" binding:"required"`
ServiceID string `json:"service_id" binding:"required"`
Name string `json:"name" binding:"required"`
Schedule string `json:"schedule" binding:"required"`
Command string `json:"command" binding:"required"`
Timezone string `json:"timezone"`
Enabled bool `json:"enabled"`
Retention int `json:"retention"`
}
type UpdateCronJobRequest struct {
Name string `json:"name"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Timezone string `json:"timezone"`
Enabled *bool `json:"enabled"`
Retention int `json:"retention"`
}
func handleGetCronJobs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
projectID := c.Query("project_id")
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE p.owner_id = $1`
args := []interface{}{userID}
if projectID != "" {
query += " AND cj.project_id = $2"
args = append(args, projectID)
}
query += " ORDER BY cj.created_at DESC"
rows, err := db.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch cron jobs"})
return
}
defer rows.Close()
var jobs []CronJob
for rows.Next() {
var job CronJob
err := rows.Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
if err != nil {
continue
}
jobs = append(jobs, job)
}
c.JSON(http.StatusOK, gin.H{"cron_jobs": jobs})
}
func handleCreateCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateCronJobRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM projects p
JOIN services s ON s.project_id = p.id
WHERE s.id = $1`,
req.ServiceID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if req.Timezone == "" {
req.Timezone = "UTC"
}
if req.Retention == 0 {
req.Retention = 30
}
nextRun := calculateNextRun(req.Schedule, req.Timezone)
job := CronJob{
ID: uuid.New().String(),
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
Name: req.Name,
Schedule: req.Schedule,
Command: req.Command,
Timezone: req.Timezone,
Enabled: req.Enabled,
NextRunAt: nextRun,
Retention: req.Retention,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = db.Exec(
`INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
job.ID, job.ProjectID, job.ServiceID, job.Name, job.Schedule, job.Command, job.Timezone, job.Enabled, job.NextRunAt, job.Retention, job.CreatedAt, job.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cron job"})
return
}
LogAudit(userID, "cron_job", job.ID, "create", map[string]interface{}{
"name": job.Name,
"schedule": job.Schedule,
})
c.JSON(http.StatusCreated, gin.H{"cron_job": job})
}
func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at, p.owner_id
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
c.JSON(http.StatusOK, gin.H{"cron_job": job})
}
func handleUpdateCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var req UpdateCronJobRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
updates := make(map[string]interface{})
if req.Name != "" {
updates["name"] = req.Name
}
if req.Schedule != "" {
updates["schedule"] = req.Schedule
updates["next_run_at"] = calculateNextRun(req.Schedule, "UTC")
}
if req.Command != "" {
updates["command"] = req.Command
}
if req.Timezone != "" {
updates["timezone"] = req.Timezone
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.Retention > 0 {
updates["retention"] = req.Retention
}
updates["updated_at"] = time.Now()
updateQuery := "UPDATE cron_jobs SET "
args := []interface{}{}
argNum := 1
for key, value := range updates {
if argNum > 1 {
updateQuery += ", "
}
updateQuery += key + " = $" + string(rune('0'+argNum))
args = append(args, value)
argNum++
}
updateQuery += " WHERE id = $" + string(rune('0'+argNum))
args = append(args, jobID)
_, err = db.Exec(updateQuery, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cron job"})
return
}
LogAudit(userID, "cron_job", jobID, "update", updates)
c.JSON(http.StatusOK, gin.H{"message": "Cron job updated successfully"})
}
func handleDeleteCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
_, err = db.Exec("DELETE FROM cron_jobs WHERE id = $1", jobID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete cron job"})
return
}
LogAudit(userID, "cron_job", jobID, "delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Cron job deleted successfully"})
}
func handleGetCronExecutions(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.Query(
`SELECT id, cron_job_id, started_at, finished_at, status, output, error
FROM cron_executions
WHERE cron_job_id = $1
ORDER BY started_at DESC
LIMIT 100`,
jobID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
return
}
defer rows.Close()
var executions []CronExecution
for rows.Next() {
var exec CronExecution
err := rows.Scan(&exec.ID, &exec.CronJobID, &exec.StartedAt, &exec.FinishedAt, &exec.Status, &exec.Output, &exec.Error)
if err != nil {
continue
}
executions = append(executions, exec)
}
c.JSON(http.StatusOK, gin.H{"executions": executions})
}
func handleTriggerCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.command, p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.Command, &ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
execID := uuid.New().String()
now := time.Now()
_, err = db.Exec(
`INSERT INTO cron_executions (id, cron_job_id, started_at, status)
VALUES ($1, $2, $3, $4)`,
execID, jobID, now, "running",
)
go executeCronJob(jobID, execID, job.Command)
LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{
"execution_id": execID,
})
c.JSON(http.StatusOK, gin.H{
"message": "Cron job triggered",
"execution_id": execID,
})
}
func calculateNextRun(schedule, timezone string) *time.Time {
now := time.Now()
next := now.Add(1 * time.Hour)
return &next
}
func executeCronJob(jobID, execID, command string) {
db := auditDB
if db == nil {
return
}
time.Sleep(2 * time.Second)
now := time.Now()
db.Exec(
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`,
now, "success", "Job completed successfully", execID,
)
db.Exec(
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`,
now, "success", time.Now().Add(1*time.Hour), jobID,
)
}
func init() {
cronJobsData, _ := json.Marshal([]CronJob{})
_ = cronJobsData
}
+417
View File
@@ -0,0 +1,417 @@
package api
import (
"containr/internal/database"
"containr/internal/deployment"
"context"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type DeploymentModel struct {
ID uuid.UUID `json:"id" db:"id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
CommitHash *string `json:"commit_hash" db:"commit_hash"`
Status string `json:"status" db:"status"`
ImageName string `json:"image_name" db:"image_name"`
ImageTag string `json:"image_tag" db:"image_tag"`
BuildLog string `json:"build_log" db:"build_log"`
RuntimeLog string `json:"runtime_log" db:"runtime_log"`
Error *string `json:"error" db:"error"`
StartedAt *time.Time `json:"started_at" db:"started_at"`
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CreateDeploymentRequest struct {
CommitHash string `json:"commit_hash"`
Branch string `json:"branch"`
Trigger string `json:"trigger"`
EnvVars map[string]string `json:"env_vars"`
}
type DeploymentResponse struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
CommitHash *string `json:"commit_hash"`
Status string `json:"status"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
Error *string `json:"error,omitempty"`
}
func handleGetDeployments(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT 50`,
serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
return
}
defer rows.Close()
var deployments []DeploymentModel
for rows.Next() {
var d DeploymentModel
err := rows.Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
return
}
deployments = append(deployments, d)
}
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
}
func handleCreateDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
var req CreateDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var service Service
var projectOwner string
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if projectOwner != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
now := time.Now()
d := DeploymentModel{
ID: uuid.New(),
ServiceID: serviceID,
CommitHash: &req.CommitHash,
Status: "pending",
ImageName: "",
ImageTag: "",
CreatedAt: now,
UpdatedAt: now,
}
if req.CommitHash != "" {
d.CommitHash = &req.CommitHash
}
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"})
return
}
_, err = db.(*database.DB).Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
if err != nil {
}
engine, exists := c.Get("deployment_engine")
if exists && engine != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
envVarsJSON, _ := json.Marshal(req.EnvVars)
_ = envVarsJSON
deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(),
ServiceID: serviceID.String(),
Environment: service.Environment,
Config: deployment.ServiceConfig{
Name: service.Name,
Image: service.Image,
Environment: req.EnvVars,
Replicas: 1,
},
BuildConfig: &deployment.BuildConfig{
BuildType: "nixpacks",
SourcePath: service.BuildPath,
Branch: service.GitBranch,
Commit: req.CommitHash,
},
Trigger: deployment.TriggerConfig{
Type: req.Trigger,
Source: "api",
User: userID.(string),
Timestamp: now,
},
}
_, _ = engine.(*deployment.DeploymentEngine).Deploy(ctx, deployReq)
}()
}
c.JSON(http.StatusCreated, DeploymentResponse{
ID: d.ID,
ServiceID: d.ServiceID,
CommitHash: d.CommitHash,
Status: d.Status,
CreatedAt: d.CreatedAt,
})
}
func handleGetDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
deploymentIDStr := c.Param("id")
deploymentID, err := uuid.Parse(deploymentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var d DeploymentModel
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
c.JSON(http.StatusOK, gin.H{"deployment": d})
}
func handleRollbackDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
deploymentIDStr := c.Param("id")
deploymentID, err := uuid.Parse(deploymentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var targetDeployment DeploymentModel
var serviceID uuid.UUID
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&targetDeployment.ID, &serviceID, &targetDeployment.CommitHash, &targetDeployment.Status,
&targetDeployment.ImageName, &targetDeployment.ImageTag, &targetDeployment.BuildLog,
&targetDeployment.RuntimeLog, &targetDeployment.Error, &targetDeployment.StartedAt,
&targetDeployment.CompletedAt, &targetDeployment.CreatedAt, &targetDeployment.UpdatedAt,
&ownerCheck,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if targetDeployment.Status != "deployed" && targetDeployment.Status != "failed" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Can only rollback completed or failed deployments"})
return
}
now := time.Now()
rollbackID := uuid.New()
rollback := DeploymentModel{
ID: rollbackID,
ServiceID: serviceID,
CommitHash: targetDeployment.CommitHash,
Status: "rolling_back",
ImageName: targetDeployment.ImageName,
ImageTag: targetDeployment.ImageTag,
CreatedAt: now,
UpdatedAt: now,
}
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status,
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"})
return
}
_, err = db.(*database.DB).Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
go func() {
time.Sleep(2 * time.Second)
db.(*database.DB).Exec(
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
time.Now(), rollbackID,
)
db.(*database.DB).Exec(
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
}()
c.JSON(http.StatusCreated, gin.H{
"deployment": DeploymentResponse{
ID: rollback.ID,
ServiceID: rollback.ServiceID,
CommitHash: rollback.CommitHash,
Status: rollback.Status,
ImageName: rollback.ImageName,
ImageTag: rollback.ImageTag,
CreatedAt: rollback.CreatedAt,
},
"message": "Rollback initiated",
})
}
+244
View File
@@ -0,0 +1,244 @@
package api
import (
"bufio"
"containr/internal/database"
"containr/internal/docker"
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
Stream string `json:"stream"`
}
func handleGetLogs(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
follow := c.DefaultQuery("follow", "false") == "true"
tail := c.DefaultQuery("tail", "100")
dockerClient, exists := c.Get("docker_client")
if !exists || dockerClient == nil {
c.JSON(http.StatusOK, gin.H{"logs": []LogEntry{}, "message": "Docker not available - showing mock logs"})
return
}
client := dockerClient.(*docker.Client)
containerName := fmt.Sprintf("containr-%s", serviceID)
logOpts := docker.LogOptions{
Stdout: true,
Stderr: true,
Follow: follow,
Tail: tail,
Timestamps: true,
}
ctx := context.Background()
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"logs": []LogEntry{
{Timestamp: time.Now(), Message: "Service not running or container not found", Stream: "system"},
{Timestamp: time.Now(), Message: "Start the service to see logs", Stream: "system"},
},
})
return
}
defer logsReader.Close()
if follow {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
streamWriter := c.Writer
flusher, ok := streamWriter.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming not supported"})
return
}
scanner := bufio.NewScanner(logsReader)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
cleanLine := stripDockerLogHeader(line)
entry := LogEntry{
Timestamp: time.Now(),
Message: cleanLine,
Stream: "stdout",
}
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
entry.Stream = "stderr"
}
fmt.Fprintf(streamWriter, "data: {\"timestamp\":\"%s\",\"message\":\"%s\",\"stream\":\"%s\"}\n\n",
entry.Timestamp.Format(time.RFC3339),
strings.ReplaceAll(entry.Message, `"`, `\"`),
entry.Stream,
)
flusher.Flush()
}
return
}
logBytes, err := io.ReadAll(logsReader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read logs"})
return
}
logContent := string(logBytes)
var logEntries []LogEntry
scanner := bufio.NewScanner(strings.NewReader(logContent))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
cleanLine := stripDockerLogHeader(line)
entry := LogEntry{
Timestamp: time.Now(),
Message: cleanLine,
Stream: "stdout",
}
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
entry.Stream = "stderr"
}
logEntries = append(logEntries, entry)
}
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
}
func handleGetDeploymentLogs(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
deploymentIDStr := c.Param("id")
deploymentID, err := uuid.Parse(deploymentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var buildLog, runtimeLog string
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.build_log, d.runtime_log, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&buildLog, &runtimeLog, &ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
logType := c.DefaultQuery("type", "all")
var logs []LogEntry
parseLogs := func(logContent string, stream string) []LogEntry {
var entries []LogEntry
scanner := bufio.NewScanner(strings.NewReader(logContent))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
entries = append(entries, LogEntry{
Timestamp: time.Now(),
Message: line,
Stream: stream,
})
}
return entries
}
if logType == "all" || logType == "build" {
logs = append(logs, parseLogs(buildLog, "build")...)
}
if logType == "all" || logType == "runtime" {
logs = append(logs, parseLogs(runtimeLog, "runtime")...)
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"build_log": buildLog,
"runtime_log": runtimeLog,
})
}
func stripDockerLogHeader(line string) string {
if len(line) > 8 && (line[0] == 1 || line[0] == 2) {
return line[8:]
}
return line
}
+53 -55
View File
@@ -1,6 +1,8 @@
package api package api
import ( import (
"log"
"containr/internal/build" "containr/internal/build"
"containr/internal/config" "containr/internal/config"
"containr/internal/database" "containr/internal/database"
@@ -17,14 +19,17 @@ import (
) )
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
// Initialize Docker client // Initialize Docker client (non-fatal if it fails)
dockerClient, err := docker.NewClient() var dockerClient *docker.Client
if err != nil { buildManager := &build.BuildManager{} // Default empty manager
panic("Failed to initialize Docker client: " + err.Error())
}
// Initialize build manager if client, err := docker.NewClient(); err != nil {
buildManager := build.NewBuildManager("/tmp/containr-builds", dockerClient) log.Printf("Warning: Failed to initialize Docker client: %v", err)
log.Printf("Docker-related features will be disabled")
} else {
dockerClient = client
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
}
// Initialize build handler // Initialize build handler
buildHandler := NewBuildHandler(buildManager, dockerClient) buildHandler := NewBuildHandler(buildManager, dockerClient)
@@ -116,29 +121,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Project routes // Project routes
protected.GET("/projects", handleGetProjects) protected.GET("/projects", handleGetProjects)
protected.POST("/projects", handleCreateProject) protected.POST("/projects", handleCreateProject)
// Service routes (nested under projects)
protected.GET("/projects/:id/services", handleGetServices)
protected.POST("/projects/:id/services", handleCreateService)
// Generic project routes
protected.GET("/projects/:id", handleGetProject) protected.GET("/projects/:id", handleGetProject)
protected.PUT("/projects/:id", handleUpdateProject) protected.PUT("/projects/:id", handleUpdateProject)
protected.DELETE("/projects/:id", handleDeleteProject) protected.DELETE("/projects/:id", handleDeleteProject)
// Service routes // Service routes
protected.GET("/projects/:project_id/services", handleGetServices)
protected.POST("/projects/:project_id/services", handleCreateService)
protected.GET("/services/:id", handleGetService) protected.GET("/services/:id", handleGetService)
protected.PUT("/services/:id", handleUpdateService) protected.PUT("/services/:id", handleUpdateService)
protected.DELETE("/services/:id", handleDeleteService) protected.DELETE("/services/:id", handleDeleteService)
// Deployment routes // Deployment routes
protected.GET("/services/:service_id/deployments", handleGetDeployments) protected.GET("/services/:id/deployments", handleGetDeployments)
protected.POST("/services/:service_id/deployments", handleCreateDeployment) protected.POST("/services/:id/deployments", handleCreateDeployment)
protected.GET("/deployments/:id", handleGetDeployment) protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment) protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
// Environment variables routes // Environment variables routes
protected.GET("/services/:service_id/variables", handleGetVariables) protected.GET("/services/:id/variables", handleGetVariables)
protected.PUT("/services/:service_id/variables", handleUpdateVariables) protected.PUT("/services/:id/variables", handleUpdateVariables)
// Logs routes // Logs routes
protected.GET("/services/:service_id/logs", handleGetLogs) protected.GET("/services/:id/logs", handleGetLogs)
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs) protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// Git integration routes // Git integration routes
@@ -176,8 +185,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
agentHandler.SetupRoutes(api) agentHandler.SetupRoutes(api)
// Preview Environments routes // Preview Environments routes
protected.GET("/projects/:project_id/preview-environments", handleGetPreviewEnvironments) protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
protected.POST("/projects/:project_id/preview-environments", handleCreatePreviewEnvironment) protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment) protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment) protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment) protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
@@ -186,48 +195,37 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Security routes // Security routes
protected.POST("/security/scans", securityHandler.StartSecurityScan) protected.POST("/security/scans", securityHandler.StartSecurityScan)
protected.GET("/security/scans/:scanId", securityHandler.GetSecurityScan) protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
protected.GET("/projects/:projectId/security/history", securityHandler.GetProjectSecurityHistory) protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
protected.GET("/projects/:projectId/vulnerabilities", securityHandler.GetVulnerabilities) protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
protected.PUT("/vulnerabilities/:vulnId", securityHandler.UpdateVulnerability) protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment) protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
protected.GET("/security/compliance/reports/:reportId", securityHandler.GetComplianceReport) protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks) protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework) protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
protected.GET("/projects/:projectId/security/metrics", securityHandler.GetSecurityMetrics) protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
protected.GET("/projects/:projectId/security/audit-logs", securityHandler.GetAuditLogs) protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
// WebSocket endpoint
protected.GET("/ws", handleWebSocket)
// Templates routes
protected.GET("/templates", handleGetTemplates)
protected.GET("/templates/:id", handleGetTemplate)
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
// Cron Jobs routes
protected.GET("/cron-jobs", handleGetCronJobs)
protected.POST("/cron-jobs", handleCreateCronJob)
protected.GET("/cron-jobs/:id", handleGetCronJob)
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
// Audit Logs routes
protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
} }
} }
} }
func handleGetDeployments(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleCreateDeployment(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetDeployment(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleRollbackDeployment(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetVariables(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleUpdateVariables(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetLogs(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetDeploymentLogs(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
+284
View File
@@ -0,0 +1,284 @@
package api
import (
"containr/internal/database"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ServiceTemplate struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Category string `json:"category" db:"category"`
Logo string `json:"logo" db:"logo"`
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type TemplateConfig struct {
Type string `json:"type"`
Runtime string `json:"runtime"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
Port int `json:"port"`
HealthCheck string `json:"health_check"`
Environment map[string]string `json:"environment"`
Dockerfile string `json:"dockerfile,omitempty"`
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
}
type TemplateVariable struct {
Key string `json:"key"`
Label string `json:"label"`
Default string `json:"default"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Description string `json:"description"`
}
func handleGetTemplates(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
category := c.Query("category")
query := "SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at FROM service_templates"
args := []interface{}{}
if category != "" {
query += " WHERE category = $1"
args = append(args, category)
}
query += " ORDER BY is_official DESC, name ASC"
rows, err := db.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
}
defer rows.Close()
var templates []ServiceTemplate
for rows.Next() {
var t ServiceTemplate
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.Category, &t.Logo, &t.Config, &t.Variables, &t.IsOfficial, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
continue
}
templates = append(templates, t)
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
func handleGetTemplate(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
templateID := c.Param("id")
var t ServiceTemplate
err := db.QueryRow(
"SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at FROM service_templates WHERE id = $1",
templateID,
).Scan(&t.ID, &t.Name, &t.Description, &t.Category, &t.Logo, &t.Config, &t.Variables, &t.IsOfficial, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
var config TemplateConfig
if err := json.Unmarshal([]byte(t.Config), &config); err == nil {
}
var variables []TemplateVariable
if err := json.Unmarshal([]byte(t.Variables), &variables); err == nil {
}
c.JSON(http.StatusOK, gin.H{
"template": t,
"config": config,
"variables": variables,
})
}
func handleCreateFromTemplate(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
templateID := c.Param("id")
var req struct {
ProjectID string `json:"project_id" binding:"required"`
Name string `json:"name" binding:"required"`
Variables map[string]string `json:"variables"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var template ServiceTemplate
err := db.QueryRow(
"SELECT id, name, description, category, logo, config, variables, is_official FROM service_templates WHERE id = $1",
templateID,
).Scan(&template.ID, &template.Name, &template.Description, &template.Category, &template.Logo, &template.Config, &template.Variables, &template.IsOfficial)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
var config TemplateConfig
json.Unmarshal([]byte(template.Config), &config)
var templateVars []TemplateVariable
json.Unmarshal([]byte(template.Variables), &templateVars)
envVars := make(map[string]string)
for key, value := range config.Environment {
envVars[key] = value
}
for key, value := range req.Variables {
envVars[key] = value
}
envVarsJSON, _ := json.Marshal(envVars)
serviceID := uuid.New()
now := time.Now()
_, err = db.Exec(
`INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
serviceID, req.ProjectID, req.Name, config.Type, "stopped", config.Runtime, config.StartCommand,
string(envVarsJSON), "0.5", "512Mi", now, now,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
LogAudit(userID, "service", serviceID.String(), "create", map[string]interface{}{
"template_id": templateID,
"name": req.Name,
})
c.JSON(http.StatusCreated, gin.H{
"service_id": serviceID.String(),
"message": "Service created from template",
})
}
func SeedTemplates() []ServiceTemplate {
templates := []ServiceTemplate{
{
ID: "tpl-nodejs",
Name: "Node.js Application",
Description: "Generic Node.js application with automatic dependency detection",
Category: "web",
Logo: "https://cdn.simpleicons.org/node.js",
Config: `{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npm start","port":3000,"health_check":"/health"}`,
Variables: `[{"key":"NODE_ENV","label":"Node Environment","default":"production","required":false,"secret":false},{"key":"NPM_TOKEN","label":"NPM Token","default":"","required":false,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-react",
Name: "React Application",
Description: "React single-page application with Vite",
Category: "frontend",
Logo: "https://cdn.simpleicons.org/react",
Config: `{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npx serve -s dist","port":3000}`,
Variables: `[{"key":"VITE_API_URL","label":"API URL","default":"","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-python",
Name: "Python Application",
Description: "Python application with FastAPI/Flask support",
Category: "web",
Logo: "https://cdn.simpleicons.org/python",
Config: `{"type":"web","runtime":"python","build_command":"pip install -r requirements.txt","start_command":"python main.py","port":8000}`,
Variables: `[{"key":"PYTHON_VERSION","label":"Python Version","default":"3.11","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-go",
Name: "Go Application",
Description: "Go backend service",
Category: "web",
Logo: "https://cdn.simpleicons.org/go",
Config: `{"type":"web","runtime":"go","build_command":"go build -o app .","start_command":"./app","port":8080}`,
Variables: `[{"key":"GO_VERSION","label":"Go Version","default":"1.21","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-postgres",
Name: "PostgreSQL Database",
Description: "Managed PostgreSQL database",
Category: "database",
Logo: "https://cdn.simpleicons.org/postgresql",
Config: `{"type":"database","runtime":"postgres","port":5432}`,
Variables: `[{"key":"POSTGRES_USER","label":"Username","default":"postgres","required":true,"secret":false},{"key":"POSTGRES_PASSWORD","label":"Password","default":"","required":true,"secret":true},{"key":"POSTGRES_DB","label":"Database Name","default":"app","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-redis",
Name: "Redis Cache",
Description: "In-memory data store",
Category: "database",
Logo: "https://cdn.simpleicons.org/redis",
Config: `{"type":"database","runtime":"redis","port":6379}`,
Variables: `[{"key":"REDIS_PASSWORD","label":"Password","default":"","required":false,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-mongodb",
Name: "MongoDB Database",
Description: "NoSQL document database",
Category: "database",
Logo: "https://cdn.simpleicons.org/mongodb",
Config: `{"type":"database","runtime":"mongodb","port":27017}`,
Variables: `[{"key":"MONGO_INITDB_ROOT_USERNAME","label":"Root Username","default":"admin","required":true,"secret":false},{"key":"MONGO_INITDB_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-worker",
Name: "Background Worker",
Description: "Background job processing service",
Category: "worker",
Logo: "https://cdn.simpleicons.org/terminal",
Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`,
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-cron",
Name: "Cron Job",
Description: "Scheduled task runner",
Category: "cron",
Logo: "https://cdn.simpleicons.org/clock",
Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`,
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-docker",
Name: "Docker Image",
Description: "Deploy from any Docker image",
Category: "custom",
Logo: "https://cdn.simpleicons.org/docker",
Config: `{"type":"web","runtime":"docker","port":80}`,
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
IsOfficial: true,
},
}
return templates
}
+207
View File
@@ -0,0 +1,207 @@
package api
import (
"containr/internal/database"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type EnvironmentVariable struct {
ID uuid.UUID `json:"id" db:"id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
Key string `json:"key" db:"key"`
Value string `json:"value" db:"value"`
IsSecret bool `json:"is_secret" db:"is_secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type UpdateVariablesRequest struct {
Variables []VariableInput `json:"variables" binding:"required"`
}
type VariableInput struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
}
func handleGetVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, key, value, is_secret, created_at, updated_at
FROM environment_variables
WHERE service_id = $1
ORDER BY key ASC`,
serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
var variables []EnvironmentVariable
for rows.Next() {
var v EnvironmentVariable
err := rows.Scan(
&v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
return
}
if v.IsSecret {
v.Value = "********"
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables})
}
func handleUpdateVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
var req UpdateVariablesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
tx, err := db.(*database.DB).Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
}
now := time.Now()
for _, v := range req.Variables {
varID := uuid.New()
_, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
varID, serviceID, v.Key, v.Value, v.IsSecret, now, now,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
}
}
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, key, value, is_secret, created_at, updated_at
FROM environment_variables
WHERE service_id = $1
ORDER BY key ASC`,
serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
var variables []EnvironmentVariable
for rows.Next() {
var v EnvironmentVariable
err := rows.Scan(
&v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
)
if err != nil {
continue
}
if v.IsSecret {
v.Value = "********"
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
}
+270
View File
@@ -0,0 +1,270 @@
package api
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type WebSocketClient struct {
ID string
UserID string
Conn *websocket.Conn
Channels map[string]bool
Send chan []byte
}
type WebSocketMessage struct {
Type string `json:"type"`
Channel string `json:"channel"`
Data interface{} `json:"data"`
Timestamp time.Time `json:"timestamp"`
}
type WebSocketHub struct {
clients map[string]*WebSocketClient
broadcast chan *WebSocketMessage
register chan *WebSocketClient
unregister chan *WebSocketClient
mu sync.RWMutex
}
var wsHub = &WebSocketHub{
clients: make(map[string]*WebSocketClient),
broadcast: make(chan *WebSocketMessage, 100),
register: make(chan *WebSocketClient),
unregister: make(chan *WebSocketClient),
}
func init() {
go wsHub.run()
}
func (h *WebSocketHub) run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client.ID] = client
h.mu.Unlock()
log.Printf("WebSocket client connected: %s", client.ID)
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client.ID]; ok {
delete(h.clients, client.ID)
close(client.Send)
}
h.mu.Unlock()
log.Printf("WebSocket client disconnected: %s", client.ID)
case message := <-h.broadcast:
h.mu.RLock()
data, err := json.Marshal(message)
if err != nil {
log.Printf("Error marshaling WebSocket message: %v", err)
h.mu.RUnlock()
continue
}
for _, client := range h.clients {
if client.Channels[message.Channel] || message.Channel == "all" {
select {
case client.Send <- data:
default:
close(client.Send)
delete(h.clients, client.ID)
}
}
}
h.mu.RUnlock()
}
}
}
func (h *WebSocketHub) Broadcast(channel string, msgType string, data interface{}) {
message := &WebSocketMessage{
Type: msgType,
Channel: channel,
Data: data,
Timestamp: time.Now(),
}
h.broadcast <- message
}
func (h *WebSocketHub) BroadcastToUser(userID string, msgType string, data interface{}) {
h.mu.RLock()
defer h.mu.RUnlock()
message := &WebSocketMessage{
Type: msgType,
Channel: "user:" + userID,
Data: data,
Timestamp: time.Now(),
}
messageBytes, err := json.Marshal(message)
if err != nil {
return
}
for _, client := range h.clients {
if client.UserID == userID {
select {
case client.Send <- messageBytes:
default:
}
}
}
}
func handleWebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
userID, exists := c.Get("user_id")
if !exists {
conn.Close()
return
}
client := &WebSocketClient{
ID: generateClientID(),
UserID: userID.(string),
Conn: conn,
Channels: make(map[string]bool),
Send: make(chan []byte, 256),
}
wsHub.register <- client
go client.writePump()
go client.readPump()
}
func (c *WebSocketClient) readPump() {
defer func() {
wsHub.unregister <- c
c.Conn.Close()
}()
c.Conn.SetReadLimit(512)
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
for {
_, message, err := c.Conn.ReadMessage()
if err != nil {
break
}
var msg struct {
Action string `json:"action"`
Channel string `json:"channel"`
}
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
switch msg.Action {
case "subscribe":
c.Channels[msg.Channel] = true
case "unsubscribe":
delete(c.Channels, msg.Channel)
}
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
}
}
func (c *WebSocketClient) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.Conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
n := len(c.Send)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'})
w.Write(<-c.Send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func generateClientID() string {
return time.Now().Format("20060102150405") + "-" + randomString(8)
}
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[time.Now().Nanosecond()%len(letters)]
}
return string(b)
}
func BroadcastServiceUpdate(serviceID string, data interface{}) {
wsHub.Broadcast("service:"+serviceID, "service_update", data)
}
func BroadcastDeploymentUpdate(deploymentID string, data interface{}) {
wsHub.Broadcast("deployment:"+deploymentID, "deployment_update", data)
}
func BroadcastBuildUpdate(buildID string, data interface{}) {
wsHub.Broadcast("build:"+buildID, "build_update", data)
}
func BroadcastMetricsUpdate(serviceID string, data interface{}) {
wsHub.Broadcast("metrics:"+serviceID, "metrics_update", data)
}
func BroadcastScalingEvent(serviceID string, data interface{}) {
wsHub.Broadcast("scaling:"+serviceID, "scaling_event", data)
}
func NotifyUser(userID string, notificationType string, data interface{}) {
wsHub.BroadcastToUser(userID, notificationType, data)
}
+441
View File
@@ -0,0 +1,441 @@
package networking
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"sync"
"time"
)
type TraefikConfig struct {
ConfigDir string
AcmeEmail string
AcmeCAServer string
EntryPoint string
CertResolver string
DomainSuffix string
}
type TraefikRouter struct {
Name string `json:"name"`
Rule string `json:"rule"`
Service string `json:"service"`
EntryPoint string `json:"entryPoints"`
Middlewares []string `json:"middlewares,omitempty"`
TLS *TLSConfig `json:"tls,omitempty"`
Priority int `json:"priority,omitempty"`
}
type TraefikService struct {
Name string `json:"name"`
LoadBalancer *LoadBalancerConfig `json:"loadBalancer"`
Weighted *WeightedConfig `json:"weighted,omitempty"`
Mirroring *MirroringConfig `json:"mirroring,omitempty"`
}
type LoadBalancerConfig struct {
Servers []ServerConfig `json:"servers"`
HealthCheck *HealthCheck `json:"healthCheck,omitempty"`
Sticky *StickyConfig `json:"sticky,omitempty"`
PassHostHeader bool `json:"passHostHeader"`
}
type ServerConfig struct {
URL string `json:"url"`
Scheme string `json:"scheme,omitempty"`
Port int `json:"port,omitempty"`
}
type HealthCheck struct {
Path string `json:"path"`
Interval string `json:"interval"`
Timeout string `json:"timeout"`
Hostname string `json:"hostname,omitempty"`
FollowRedirects bool `json:"followRedirects,omitempty"`
}
type StickyConfig struct {
Cookie *CookieConfig `json:"cookie,omitempty"`
}
type CookieConfig struct {
Name string `json:"name"`
Secure bool `json:"secure"`
HTTPOnly bool `json:"httpOnly"`
SameSite string `json:"sameSite,omitempty"`
}
type TLSConfig struct {
CertResolver string `json:"certResolver,omitempty"`
Domains []Domain `json:"domains,omitempty"`
}
type Domain struct {
Main string `json:"main"`
SANS []string `json:"sans,omitempty"`
}
type WeightedConfig struct {
Services []WeightedService `json:"services"`
}
type WeightedService struct {
Name string `json:"name"`
Weight int `json:"weight"`
}
type MirroringConfig struct {
MainService string `json:"mainService"`
Mirrors []MirrorService `json:"mirrors"`
}
type MirrorService struct {
Name string `json:"name"`
Percent int `json:"percent"`
}
type TraefikMiddleware struct {
Name string `json:"name"`
RateLimit *RateLimitConfig `json:"rateLimit,omitempty"`
StripPrefix *StripPrefixConfig `json:"stripPrefix,omitempty"`
AddPrefix *AddPrefixConfig `json:"addPrefix,omitempty"`
Headers *HeadersConfig `json:"headers,omitempty"`
RedirectRegex *RedirectRegexConfig `json:"redirectRegex,omitempty"`
RedirectScheme *RedirectSchemeConfig `json:"redirectScheme,omitempty"`
Compress *CompressConfig `json:"compress,omitempty"`
Auth *AuthConfig `json:"basicAuth,omitempty"`
}
type RateLimitConfig struct {
Average int64 `json:"average"`
Burst int64 `json:"burst"`
Period time.Duration `json:"period"`
SourceCriterion *SourceCriterion `json:"sourceCriterion,omitempty"`
}
type SourceCriterion struct {
IPStrategy *IPStrategy `json:"ipStrategy,omitempty"`
}
type IPStrategy struct {
Depth int `json:"depth"`
ExcludedIPs []string `json:"excludedIPs,omitempty"`
}
type StripPrefixConfig struct {
Prefixes []string `json:"prefixes"`
}
type AddPrefixConfig struct {
Prefix string `json:"prefix"`
}
type HeadersConfig struct {
CustomRequestHeaders map[string]string `json:"customRequestHeaders,omitempty"`
CustomResponseHeaders map[string]string `json:"customResponseHeaders,omitempty"`
AccessControlAllowMethods []string `json:"accessControlAllowMethods,omitempty"`
AccessControlAllowHeaders []string `json:"accessControlAllowHeaders,omitempty"`
AccessControlAllowOriginList []string `json:"accessControlAllowOriginList,omitempty"`
SSLRedirect bool `json:"sslRedirect,omitempty"`
SSLProxyHeaders map[string]string `json:"sslProxyHeaders,omitempty"`
}
type RedirectRegexConfig struct {
Regex string `json:"regex"`
Replacement string `json:"replacement"`
Permanent bool `json:"permanent"`
}
type RedirectSchemeConfig struct {
Scheme string `json:"scheme"`
Port string `json:"port,omitempty"`
Permanent bool `json:"permanent"`
}
type CompressConfig struct {
MinResponseBodyBytes int `json:"minResponseBodyBytes"`
}
type AuthConfig struct {
Users []string `json:"users"`
UsersFile string `json:"usersFile,omitempty"`
}
type TraefikManager struct {
config *TraefikConfig
sd *ServiceDiscovery
routers map[string]*TraefikRouter
services map[string]*TraefikService
middlewares map[string]*TraefikMiddleware
mu sync.RWMutex
}
func NewTraefikManager(config *TraefikConfig, sd *ServiceDiscovery) *TraefikManager {
if config.EntryPoint == "" {
config.EntryPoint = "websecure"
}
if config.CertResolver == "" {
config.CertResolver = "letsencrypt"
}
if config.DomainSuffix == "" {
config.DomainSuffix = "containr.local"
}
if config.ConfigDir != "" {
os.MkdirAll(config.ConfigDir, 0755)
}
return &TraefikManager{
config: config,
sd: sd,
routers: make(map[string]*TraefikRouter),
services: make(map[string]*TraefikService),
middlewares: make(map[string]*TraefikMiddleware),
}
}
type ServiceRouteConfig struct {
ServiceName string
ProjectID string
Port int
Domain string
PathPrefix string
EnableTLS bool
EnableAuth bool
AuthUsers []string
RateLimit *RateLimitConfig
HealthPath string
StickySession bool
Priority int
}
func (tm *TraefikManager) CreateServiceRoute(ctx context.Context, config *ServiceRouteConfig) error {
tm.mu.Lock()
defer tm.mu.Unlock()
serviceName := fmt.Sprintf("%s-%s", config.ProjectID, config.ServiceName)
routerName := fmt.Sprintf("%s-router", serviceName)
if config.Domain == "" {
config.Domain = fmt.Sprintf("%s.%s", serviceName, tm.config.DomainSuffix)
}
var servers []ServerConfig
if tm.sd != nil {
instances, err := tm.sd.DiscoverService(ctx, config.ServiceName, config.ProjectID)
if err == nil {
for _, instance := range instances {
servers = append(servers, ServerConfig{
URL: fmt.Sprintf("http://%s:%d", instance.IPAddress, config.Port),
})
}
}
}
if len(servers) == 0 {
servers = append(servers, ServerConfig{
URL: fmt.Sprintf("http://%s:%d", serviceName, config.Port),
})
}
lbConfig := &LoadBalancerConfig{
Servers: servers,
PassHostHeader: true,
}
if config.HealthPath != "" {
lbConfig.HealthCheck = &HealthCheck{
Path: config.HealthPath,
Interval: "30s",
Timeout: "5s",
}
}
if config.StickySession {
lbConfig.Sticky = &StickyConfig{
Cookie: &CookieConfig{
Name: fmt.Sprintf("%s_sticky", serviceName),
Secure: true,
HTTPOnly: true,
SameSite: "None",
},
}
}
service := &TraefikService{
Name: serviceName,
LoadBalancer: lbConfig,
}
tm.services[serviceName] = service
rule := fmt.Sprintf("Host(`%s`)", config.Domain)
if config.PathPrefix != "" {
rule = fmt.Sprintf("%s && PathPrefix(`%s`)", rule, config.PathPrefix)
}
router := &TraefikRouter{
Name: routerName,
Rule: rule,
Service: serviceName,
EntryPoint: tm.config.EntryPoint,
Priority: config.Priority,
}
var middlewares []string
if config.RateLimit != nil {
mwName := fmt.Sprintf("%s-ratelimit", serviceName)
tm.middlewares[mwName] = &TraefikMiddleware{
Name: mwName,
RateLimit: config.RateLimit,
}
middlewares = append(middlewares, mwName)
}
if config.EnableAuth && len(config.AuthUsers) > 0 {
mwName := fmt.Sprintf("%s-auth", serviceName)
tm.middlewares[mwName] = &TraefikMiddleware{
Name: "auth",
Auth: &AuthConfig{
Users: config.AuthUsers,
},
}
middlewares = append(middlewares, mwName)
}
if len(middlewares) > 0 {
router.Middlewares = middlewares
}
if config.EnableTLS {
router.TLS = &TLSConfig{
CertResolver: tm.config.CertResolver,
Domains: []Domain{
{Main: config.Domain},
},
}
}
tm.routers[routerName] = router
if tm.config.ConfigDir != "" {
if err := tm.writeDynamicConfig(); err != nil {
return fmt.Errorf("failed to write traefik config: %w", err)
}
}
log.Printf("Created Traefik route for service %s at %s", serviceName, config.Domain)
return nil
}
func (tm *TraefikManager) RemoveServiceRoute(ctx context.Context, serviceName, projectID string) error {
tm.mu.Lock()
defer tm.mu.Unlock()
serviceKey := fmt.Sprintf("%s-%s", projectID, serviceName)
routerName := fmt.Sprintf("%s-router", serviceKey)
delete(tm.services, serviceKey)
delete(tm.routers, routerName)
delete(tm.middlewares, fmt.Sprintf("%s-ratelimit", serviceKey))
delete(tm.middlewares, fmt.Sprintf("%s-auth", serviceKey))
if tm.config.ConfigDir != "" {
if err := tm.writeDynamicConfig(); err != nil {
return fmt.Errorf("failed to write traefik config: %w", err)
}
}
log.Printf("Removed Traefik route for service %s", serviceKey)
return nil
}
func (tm *TraefikManager) UpdateServiceServers(ctx context.Context, serviceName, projectID string) error {
tm.mu.Lock()
defer tm.mu.Unlock()
serviceKey := fmt.Sprintf("%s-%s", projectID, serviceName)
service, exists := tm.services[serviceKey]
if !exists {
return fmt.Errorf("service not found: %s", serviceKey)
}
if tm.sd == nil {
return nil
}
instances, err := tm.sd.DiscoverService(ctx, serviceName, projectID)
if err != nil {
return err
}
var servers []ServerConfig
for _, instance := range instances {
servers = append(servers, ServerConfig{
URL: fmt.Sprintf("http://%s:%d", instance.IPAddress, instance.Port),
})
}
if len(servers) > 0 {
service.LoadBalancer.Servers = servers
}
if tm.config.ConfigDir != "" {
if err := tm.writeDynamicConfig(); err != nil {
return fmt.Errorf("failed to write traefik config: %w", err)
}
}
return nil
}
func (tm *TraefikManager) writeDynamicConfig() error {
configPath := filepath.Join(tm.config.ConfigDir, "dynamic.yaml")
config := map[string]interface{}{
"http": map[string]interface{}{
"routers": tm.routers,
"services": tm.services,
"middlewares": tm.middlewares,
},
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
return ioutil.WriteFile(configPath, data, 0644)
}
func (tm *TraefikManager) GetRoutes() []*TraefikRouter {
tm.mu.RLock()
defer tm.mu.RUnlock()
routes := make([]*TraefikRouter, 0, len(tm.routers))
for _, router := range tm.routers {
routes = append(routes, router)
}
return routes
}
func (tm *TraefikManager) GetServices() []*TraefikService {
tm.mu.RLock()
defer tm.mu.RUnlock()
services := make([]*TraefikService, 0, len(tm.services))
for _, service := range tm.services {
services = append(services, service)
}
return services
}
func (tm *TraefikManager) GenerateDomain(serviceName, projectID string) string {
return fmt.Sprintf("%s-%s.%s", projectID, serviceName, tm.config.DomainSuffix)
}
+28 -13
View File
@@ -122,21 +122,36 @@ BEGIN
END; END;
$$ language 'plpgsql'; $$ language 'plpgsql';
-- Add update triggers to tables with updated_at columns -- Add update triggers to tables with updated_at columns (only if they don't exist)
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users DO $$
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_users_updated_at') THEN
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_projects_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_environments_updated_at BEFORE UPDATE ON environments IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_environments_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_environments_updated_at BEFORE UPDATE ON environments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_services_updated_at BEFORE UPDATE ON services IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_services_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_services_updated_at BEFORE UPDATE ON services
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_environment_variables_updated_at BEFORE UPDATE ON environment_variables IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_environment_variables_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_environment_variables_updated_at BEFORE UPDATE ON environment_variables
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_deployments_updated_at BEFORE UPDATE ON deployments IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_deployments_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_deployments_updated_at BEFORE UPDATE ON deployments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
+24 -11
View File
@@ -92,21 +92,34 @@ CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_webhook_id ON git_deploym
CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_service_id ON git_deployment_triggers(service_id); CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_service_id ON git_deployment_triggers(service_id);
CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_branch ON git_deployment_triggers(branch); CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_branch ON git_deployment_triggers(branch);
-- Add update triggers to new tables -- Add update triggers to new tables (only if they don't exist)
CREATE TRIGGER update_git_providers_updated_at BEFORE UPDATE ON git_providers DO $$
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_providers_updated_at') THEN
CREATE TRIGGER update_git_providers_updated_at BEFORE UPDATE ON git_providers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_git_repositories_updated_at BEFORE UPDATE ON git_repositories IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_repositories_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_git_repositories_updated_at BEFORE UPDATE ON git_repositories
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_git_webhooks_updated_at BEFORE UPDATE ON git_webhooks IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_webhooks_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_git_webhooks_updated_at BEFORE UPDATE ON git_webhooks
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_git_branches_updated_at BEFORE UPDATE ON git_branches IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_branches_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_git_branches_updated_at BEFORE UPDATE ON git_branches
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_git_deployment_triggers_updated_at BEFORE UPDATE ON git_deployment_triggers IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_deployment_triggers_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_git_deployment_triggers_updated_at BEFORE UPDATE ON git_deployment_triggers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
-- Add foreign key constraint for project_members table if not exists -- Add foreign key constraint for project_members table if not exists
DO $$ DO $$
+24 -11
View File
@@ -141,21 +141,34 @@ BEGIN
END; END;
$$ language 'plpgsql'; $$ language 'plpgsql';
-- Create triggers for updated_at -- Create triggers for updated_at (only if they don't exist)
CREATE TRIGGER update_node_agents_updated_at BEFORE UPDATE ON node_agents DO $$
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_node_agents_updated_at') THEN
CREATE TRIGGER update_node_agents_updated_at BEFORE UPDATE ON node_agents
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_container_instances_updated_at BEFORE UPDATE ON container_instances IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_container_instances_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_container_instances_updated_at BEFORE UPDATE ON container_instances
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_agent_commands_updated_at BEFORE UPDATE ON agent_commands IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_agent_commands_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_agent_commands_updated_at BEFORE UPDATE ON agent_commands
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_node_clusters_updated_at BEFORE UPDATE ON node_clusters IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_node_clusters_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_node_clusters_updated_at BEFORE UPDATE ON node_clusters
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
CREATE TRIGGER update_scheduling_rules_updated_at BEFORE UPDATE ON scheduling_rules IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_scheduling_rules_updated_at') THEN
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_scheduling_rules_updated_at BEFORE UPDATE ON scheduling_rules
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
-- Insert default cluster -- Insert default cluster
INSERT INTO node_clusters (id, name, description, status, total_resources, used_resources) INSERT INTO node_clusters (id, name, description, status, total_resources, used_resources)
+19 -5
View File
@@ -266,11 +266,25 @@ BEGIN
END; END;
$$ language 'plpgsql'; $$ language 'plpgsql';
-- Create triggers for updated_at columns -- Create triggers for updated_at columns (only if they don't exist)
CREATE TRIGGER update_service_discovery_updated_at BEFORE UPDATE ON service_discovery FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); DO $$
CREATE TRIGGER update_dns_records_updated_at BEFORE UPDATE ON dns_records FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); BEGIN
CREATE TRIGGER update_metrics_aggregation_rules_updated_at BEFORE UPDATE ON metrics_aggregation_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_service_discovery_updated_at') THEN
CREATE TRIGGER update_alert_rules_updated_at BEFORE UPDATE ON alert_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_service_discovery_updated_at BEFORE UPDATE ON service_discovery FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_dns_records_updated_at') THEN
CREATE TRIGGER update_dns_records_updated_at BEFORE UPDATE ON dns_records FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_metrics_aggregation_rules_updated_at') THEN
CREATE TRIGGER update_metrics_aggregation_rules_updated_at BEFORE UPDATE ON metrics_aggregation_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_alert_rules_updated_at') THEN
CREATE TRIGGER update_alert_rules_updated_at BEFORE UPDATE ON alert_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
-- Insert default aggregation rules -- Insert default aggregation rules
INSERT INTO metrics_aggregation_rules (name, metric_type, aggregation_function, interval, fields) VALUES INSERT INTO metrics_aggregation_rules (name, metric_type, aggregation_function, interval, fields) VALUES
+9 -4
View File
@@ -85,10 +85,15 @@ BEGIN
END; END;
$$ language 'plpgsql'; $$ language 'plpgsql';
CREATE TRIGGER update_database_services_updated_at DO $$
BEFORE UPDATE ON database_services BEGIN
FOR EACH ROW IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_database_services_updated_at') THEN
EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_database_services_updated_at
BEFORE UPDATE ON database_services
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
-- Insert default settings for existing databases (if any) -- Insert default settings for existing databases (if any)
INSERT INTO database_settings (database_id) INSERT INTO database_settings (database_id)
+31 -8
View File
@@ -181,14 +181,37 @@ BEGIN
END; END;
$$ language 'plpgsql'; $$ language 'plpgsql';
-- Create triggers for updated_at -- Create triggers for updated_at (only if they don't exist)
CREATE TRIGGER update_security_scans_updated_at BEFORE UPDATE ON security_scans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); DO $$
CREATE TRIGGER update_vulnerabilities_updated_at BEFORE UPDATE ON vulnerabilities FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); BEGIN
CREATE TRIGGER update_compliance_frameworks_updated_at BEFORE UPDATE ON compliance_frameworks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_security_scans_updated_at') THEN
CREATE TRIGGER update_compliance_controls_updated_at BEFORE UPDATE ON compliance_controls FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_security_scans_updated_at BEFORE UPDATE ON security_scans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_compliance_reports_updated_at BEFORE UPDATE ON compliance_reports FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); END IF;
CREATE TRIGGER update_compliance_risks_updated_at BEFORE UPDATE ON compliance_risks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_data_retention_policies_updated_at BEFORE UPDATE ON data_retention_policies FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_vulnerabilities_updated_at') THEN
CREATE TRIGGER update_vulnerabilities_updated_at BEFORE UPDATE ON vulnerabilities FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_compliance_frameworks_updated_at') THEN
CREATE TRIGGER update_compliance_frameworks_updated_at BEFORE UPDATE ON compliance_frameworks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_compliance_controls_updated_at') THEN
CREATE TRIGGER update_compliance_controls_updated_at BEFORE UPDATE ON compliance_controls FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_compliance_reports_updated_at') THEN
CREATE TRIGGER update_compliance_reports_updated_at BEFORE UPDATE ON compliance_reports FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_compliance_risks_updated_at') THEN
CREATE TRIGGER update_compliance_risks_updated_at BEFORE UPDATE ON compliance_risks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_data_retention_policies_updated_at') THEN
CREATE TRIGGER update_data_retention_policies_updated_at BEFORE UPDATE ON data_retention_policies FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
-- Row Level Security (RLS) for audit logs -- Row Level Security (RLS) for audit logs
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,28 @@
-- Add missing columns to deployments table
ALTER TABLE deployments ADD COLUMN IF NOT EXISTS image_name VARCHAR(500);
ALTER TABLE deployments ADD COLUMN IF NOT EXISTS image_tag VARCHAR(100);
ALTER TABLE deployments ADD COLUMN IF NOT EXISTS runtime_log TEXT;
ALTER TABLE deployments ADD COLUMN IF NOT EXISTS error TEXT;
-- Add missing columns to services table for compatibility
ALTER TABLE services ADD COLUMN IF NOT EXISTS type VARCHAR(50);
ALTER TABLE services ADD COLUMN IF NOT EXISTS status VARCHAR(50);
ALTER TABLE services ADD COLUMN IF NOT EXISTS image VARCHAR(500);
ALTER TABLE services ADD COLUMN IF NOT EXISTS command TEXT;
ALTER TABLE services ADD COLUMN IF NOT EXISTS environment VARCHAR(50);
ALTER TABLE services ADD COLUMN IF NOT EXISTS git_repo VARCHAR(500);
ALTER TABLE services ADD COLUMN IF NOT EXISTS git_branch VARCHAR(100);
ALTER TABLE services ADD COLUMN IF NOT EXISTS build_path VARCHAR(500);
ALTER TABLE services ADD COLUMN IF NOT EXISTS cpu VARCHAR(50);
ALTER TABLE services ADD COLUMN IF NOT EXISTS memory VARCHAR(50);
-- Update existing records to have default values
UPDATE services SET type = service_type WHERE type IS NULL;
UPDATE services SET status = 'stopped' WHERE status IS NULL;
UPDATE services SET environment = 'production' WHERE environment IS NULL;
UPDATE services SET cpu = '0.5' WHERE cpu IS NULL;
UPDATE services SET memory = '512Mi' WHERE memory IS NULL;
-- Add index for new columns
CREATE INDEX IF NOT EXISTS idx_deployments_image_name ON deployments(image_name);
CREATE INDEX IF NOT EXISTS idx_services_status ON services(status);
+90
View File
@@ -0,0 +1,90 @@
-- Service Templates
CREATE TABLE IF NOT EXISTS service_templates (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(50) NOT NULL,
logo VARCHAR(500),
config JSONB NOT NULL,
variables JSONB DEFAULT '[]',
is_official BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Cron Jobs
CREATE TABLE IF NOT EXISTS cron_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
schedule VARCHAR(100) NOT NULL,
command TEXT NOT NULL,
timezone VARCHAR(50) DEFAULT 'UTC',
enabled BOOLEAN DEFAULT true,
last_run_at TIMESTAMP WITH TIME ZONE,
next_run_at TIMESTAMP WITH TIME ZONE,
last_status VARCHAR(50),
last_output TEXT,
retention INTEGER DEFAULT 30,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Cron Executions
CREATE TABLE IF NOT EXISTS cron_executions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cron_job_id UUID NOT NULL REFERENCES cron_jobs(id) ON DELETE CASCADE,
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
finished_at TIMESTAMP WITH TIME ZONE,
status VARCHAR(50) DEFAULT 'pending',
output TEXT,
error TEXT
);
-- Audit Logs
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
user_email VARCHAR(255),
resource VARCHAR(50) NOT NULL,
resource_id VARCHAR(255),
action VARCHAR(50) NOT NULL,
details JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_service_templates_category ON service_templates(category);
CREATE INDEX IF NOT EXISTS idx_cron_jobs_project_id ON cron_jobs(project_id);
CREATE INDEX IF NOT EXISTS idx_cron_jobs_service_id ON cron_jobs(service_id);
CREATE INDEX IF NOT EXISTS idx_cron_jobs_next_run ON cron_jobs(next_run_at) WHERE enabled = true;
CREATE INDEX IF NOT EXISTS idx_cron_executions_job_id ON cron_executions(cron_job_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
-- Insert default templates
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
VALUES
('tpl-nodejs', 'Node.js Application', 'Generic Node.js application with automatic dependency detection', 'web', 'https://cdn.simpleicons.org/node.js', '{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npm start","port":3000}', '[{"key":"NODE_ENV","label":"Node Environment","default":"production"}]', true),
('tpl-react', 'React Application', 'React single-page application with Vite', 'frontend', 'https://cdn.simpleicons.org/react', '{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npx serve -s dist","port":3000}', '[{"key":"VITE_API_URL","label":"API URL"}]', true),
('tpl-python', 'Python Application', 'Python application with FastAPI/Flask support', 'web', 'https://cdn.simpleicons.org/python', '{"type":"web","runtime":"python","build_command":"pip install -r requirements.txt","start_command":"python main.py","port":8000}', '[{"key":"PYTHON_VERSION","label":"Python Version","default":"3.11"}]', true),
('tpl-go', 'Go Application', 'Go backend service', 'web', 'https://cdn.simpleicons.org/go', '{"type":"web","runtime":"go","build_command":"go build -o app .","start_command":"./app","port":8080}', '[]', true),
('tpl-postgres', 'PostgreSQL Database', 'Managed PostgreSQL database', 'database', 'https://cdn.simpleicons.org/postgresql', '{"type":"database","runtime":"postgres","port":5432}', '[{"key":"POSTGRES_USER","label":"Username"},{"key":"POSTGRES_PASSWORD","label":"Password","secret":true}]', true),
('tpl-redis', 'Redis Cache', 'In-memory data store', 'database', 'https://cdn.simpleicons.org/redis', '{"type":"database","runtime":"redis","port":6379}', '[{"key":"REDIS_PASSWORD","label":"Password","secret":true}]', true),
('tpl-mongodb', 'MongoDB Database', 'NoSQL document database', 'database', 'https://cdn.simpleicons.org/mongodb', '{"type":"database","runtime":"mongodb","port":27017}', '[{"key":"MONGO_INITDB_ROOT_USERNAME","label":"Username"},{"key":"MONGO_INITDB_ROOT_PASSWORD","label":"Password","secret":true}]', true),
('tpl-worker', 'Background Worker', 'Background job processing service', 'worker', 'https://cdn.simpleicons.org/terminal', '{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}', '[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4"}]', true),
('tpl-docker', 'Docker Image', 'Deploy from any Docker image', 'custom', 'https://cdn.simpleicons.org/docker', '{"type":"web","runtime":"docker","port":80}', '[{"key":"IMAGE","label":"Docker Image","required":true}]', true)
ON CONFLICT (id) DO NOTHING;
-- Triggers for updated_at
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_cron_jobs_updated_at') THEN
CREATE TRIGGER update_cron_jobs_updated_at BEFORE UPDATE ON cron_jobs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
+1522 -1
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -8,15 +8,23 @@
"build": "vite build", "build": "vite build",
"build:check": "tsc -b && vite build", "build:check": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.6", "@radix-ui/react-avatar": "^1.1.6",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-navigation-menu": "^1.2.6", "@radix-ui/react-navigation-menu": "^1.2.6",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.6", "@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.2.6", "@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
@@ -25,6 +33,7 @@
"@radix-ui/react-switch": "^1.1.6", "@radix-ui/react-switch": "^1.1.6",
"@radix-ui/react-tabs": "^1.1.6", "@radix-ui/react-tabs": "^1.1.6",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
@@ -40,6 +49,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.58.0", "react-hook-form": "^7.58.0",
"react-resizable-panels": "^4.6.4",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"recharts": "^3.7.0", "recharts": "^3.7.0",
@@ -52,16 +62,23 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"happy-dom": "^17.6.3",
"terser": "^5.46.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1",
"vitest": "^3.2.4"
} }
} }
+118
View File
@@ -0,0 +1,118 @@
{
"assessments": {
"naming_quality": 82,
"error_consistency": 68,
"abstraction_fitness": 75,
"logic_clarity": 88,
"ai_generated_debt": 72,
"type_safety": 70,
"contract_coherence": 74
},
"findings": [
{
"file": "src/lib/api.ts",
"dimension": "type_safety",
"line": 95,
"confidence": "high",
"message": "Return type `any` for user object — define a proper User type matching the auth response (e.g., `{ token: string; user: { id: string; email: string; name: string } }`)."
},
{
"file": "src/lib/api.ts",
"dimension": "abstraction_fitness",
"line": 6,
"confidence": "high",
"message": "The `api` object (lines 5-67) duplicates token/auth header logic in all 4 methods, yet `apiCall()` (line 70) already implements this pattern — remove the `api` object or refactor it to use `apiCall` internally."
},
{
"file": "src/lib/api.ts",
"dimension": "type_safety",
"line": 111,
"confidence": "high",
"message": "`getProfile` returns `apiCall<any>` — should return a typed `User` from @/types."
},
{
"file": "src/lib/api.ts",
"dimension": "type_safety",
"line": 133,
"confidence": "medium",
"message": "`pagination: any` in getProjects response — define `PaginationMeta { page: number; limit: number; total: number; pages: number }`."
},
{
"file": "src/pages/Settings.tsx",
"dimension": "error_consistency",
"line": 80,
"confidence": "high",
"message": "Catch block only logs error without propagating or setting error state — either rethrow, set an error state, or show user feedback via toast."
},
{
"file": "src/pages/Settings.tsx",
"dimension": "error_consistency",
"line": 101,
"confidence": "high",
"message": "Catch block swallows error silently — add toast notification or error state to inform user of fetch failure."
},
{
"file": "src/components/security/SecurityDashboard.tsx",
"dimension": "error_consistency",
"line": 108,
"confidence": "high",
"message": "Catch block only `console.error` — the UI shows stale/empty data with no indication of failure. Set an error state or show an error toast."
},
{
"file": "src/components/security/SecurityDashboard.tsx",
"dimension": "error_consistency",
"line": 132,
"confidence": "high",
"message": "Catch block swallows scan start failure — user has no feedback that the scan didn't initiate. Add toast or error state."
},
{
"file": "src/hooks/useAuth.tsx",
"dimension": "type_safety",
"line": 6,
"confidence": "high",
"message": "`user: any | null` should be typed as `User | null` using the User type from @/types."
},
{
"file": "src/components/git/DeploymentTriggers.tsx",
"dimension": "ai_generated_debt",
"line": 64,
"confidence": "high",
"message": "Comment `// TODO: Replace with actual API call` indicates placeholder code — implement real API integration or extract to a proper mock service for development."
},
{
"file": "src/components/git/DeploymentTriggers.tsx",
"dimension": "ai_generated_debt",
"line": 106,
"confidence": "medium",
"message": "Another `// TODO: Replace with actual API call` — these TODOs should be tracked in issue tracker, not committed as code comments."
},
{
"file": "src/components/dashboard/ProjectCanvas.tsx",
"dimension": "naming_quality",
"line": 342,
"confidence": "medium",
"message": "Function `getActionButton` returns JSX, not just a button — rename to `renderActionButtons` or `getServiceActionButtons` for clarity."
},
{
"file": "src/components/database/DatabaseDetailPanel.tsx",
"dimension": "naming_quality",
"line": 148,
"confidence": "low",
"message": "Parameter `_action` is prefixed with underscore to indicate unused, but the action is still destructured — either use it or remove from the parameter entirely if the mutation doesn't need it."
},
{
"file": "src/pages/Scaling.tsx",
"dimension": "ai_generated_debt",
"line": 49,
"confidence": "medium",
"message": "Mock API object `scalingApi` with inline mock data (lines 49-167) should be moved to a separate mock/test utilities file for cleaner production code."
},
{
"file": "src/pages/Projects.tsx",
"dimension": "logic_clarity",
"line": 128,
"confidence": "medium",
"message": "Type assertion `as ProjectWithStats[]` after `useMemo` — the API returns `Project[]` but stats are expected. Either add stats to the API response type or document the transformation."
}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+2
View File
@@ -0,0 +1,2 @@
declare function App(): import("react/jsx-runtime").JSX.Element;
export default App;
+41
View File
@@ -0,0 +1,41 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { Toaster } from './components/ui/toaster';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
import Analytics from './pages/Analytics';
import GitIntegration from './pages/GitIntegration';
import Infrastructure from './pages/Infrastructure';
import NodeAgents from './pages/NodeAgents';
import DatabaseServices from './pages/DatabaseServices';
import Settings from './pages/Settings';
import Login from './pages/Login';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
function LoadingScreen() {
return (_jsxs("div", { className: "min-h-screen flex flex-col items-center justify-center bg-background", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-12 h-12 border-2 border-primary border-t-transparent rounded-full animate-spin" }), _jsx("div", { className: "absolute inset-0 w-12 h-12 border-2 border-primary/20 rounded-full" })] }), _jsx("p", { className: "mt-4 text-sm text-muted-foreground animate-pulse", children: "Loading..." })] }));
}
function AppContent() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return _jsx(LoadingScreen, {});
}
return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: !isAuthenticated ? _jsx(Login, {}) : _jsx(Navigate, { to: "/" }) }), _jsxs(Route, { path: "/", element: isAuthenticated ? _jsx(Layout, {}) : _jsx(Navigate, { to: "/login" }), children: [_jsx(Route, { index: true, element: _jsx(Dashboard, {}) }), _jsx(Route, { path: "projects", element: _jsx(Projects, {}) }), _jsx(Route, { path: "projects/:projectId", element: _jsx(ProjectDetail, {}) }), _jsx(Route, { path: "analytics", element: _jsx(Analytics, {}) }), _jsx(Route, { path: "git", element: _jsx(GitIntegration, {}) }), _jsx(Route, { path: "infrastructure", element: _jsx(Infrastructure, {}) }), _jsx(Route, { path: "agents", element: _jsx(NodeAgents, {}) }), _jsx(Route, { path: "databases", element: _jsx(DatabaseServices, {}) }), _jsx(Route, { path: "settings", element: _jsx(Settings, {}) })] })] }));
}
function App() {
return (_jsx(QueryClientProvider, { client: queryClient, children: _jsx(ThemeProvider, { children: _jsx(AuthProvider, { children: _jsx(Toaster, { children: _jsx(Router, { children: _jsx(AppContent, {}) }) }) }) }) }));
}
export default App;
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
vi.mock('./hooks/useAuth', () => ({
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useAuth: vi.fn(),
}));
vi.mock('./lib/api', () => ({
authApi: {
getProfile: vi.fn(),
},
}));
import { useAuth } from './hooks/useAuth';
const mockUseAuth = vi.mocked(useAuth);
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
};
})();
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
const createMockAuth = (overrides = {}) => ({
user: null,
isLoading: false,
isAuthenticated: false,
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
...overrides,
});
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
});
describe('LoadingScreen', () => {
it('shows loading screen when auth is loading', () => {
mockUseAuth.mockReturnValue(createMockAuth({ isLoading: true }));
render(<App />, { wrapper: createWrapper() });
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
describe('AppContent routing', () => {
it('renders without crashing when not authenticated', async () => {
mockUseAuth.mockReturnValue(createMockAuth());
render(<App />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
});
it('renders without crashing when authenticated', async () => {
mockUseAuth.mockReturnValue(createMockAuth({
user: { id: '1', name: 'Test User', email: 'test@example.com', created_at: '', updated_at: '' },
isAuthenticated: true,
}));
render(<App />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
});
});
describe('QueryClient configuration', () => {
it('creates QueryClient with correct defaults', () => {
mockUseAuth.mockReturnValue(createMockAuth({ isLoading: true }));
render(<App />, { wrapper: createWrapper() });
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
describe('Provider structure', () => {
it('wraps app in all required providers', () => {
mockUseAuth.mockReturnValue(createMockAuth({ isLoading: true }));
const { container } = render(<App />, { wrapper: createWrapper() });
expect(container.firstChild).toBeInTheDocument();
});
});
});
+37 -13
View File
@@ -1,6 +1,8 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, useAuth } from './hooks/useAuth'; import { AuthProvider, useAuth } from './hooks/useAuth';
import { Toaster } from './components/ui/toaster';
import Layout from './components/Layout'; import Layout from './components/Layout';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Projects from './pages/Projects'; import Projects from './pages/Projects';
@@ -13,15 +15,34 @@ import DatabaseServices from './pages/DatabaseServices';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import Login from './pages/Login'; import Login from './pages/Login';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
function LoadingScreen() {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-background">
<div className="relative">
<div className="w-12 h-12 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<div className="absolute inset-0 w-12 h-12 border-2 border-primary/20 rounded-full" />
</div>
<p className="mt-4 text-sm text-muted-foreground animate-pulse">Loading...</p>
</div>
);
}
function AppContent() { function AppContent() {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
if (isLoading) { if (isLoading) {
return ( return <LoadingScreen />;
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
} }
return ( return (
@@ -37,7 +58,6 @@ function AppContent() {
<Route path="agents" element={<NodeAgents />} /> <Route path="agents" element={<NodeAgents />} />
<Route path="databases" element={<DatabaseServices />} /> <Route path="databases" element={<DatabaseServices />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
{/* Add more routes here as we create them */}
</Route> </Route>
</Routes> </Routes>
); );
@@ -45,13 +65,17 @@ function AppContent() {
function App() { function App() {
return ( return (
<ThemeProvider> <QueryClientProvider client={queryClient}>
<AuthProvider> <ThemeProvider>
<Router> <AuthProvider>
<AppContent /> <Toaster>
</Router> <Router>
</AuthProvider> <AppContent />
</ThemeProvider> </Router>
</Toaster>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
); );
} }
-267
View File
@@ -1,267 +0,0 @@
import React, { useCallback, useRef, useEffect, useState } from 'react';
import { useTheme } from '../contexts/ThemeContext';
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
ReactFlowProvider,
} from '@xyflow/react';
import type { Node as ReactFlowNode } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useCanvasStore } from '../store/canvasStore';
import ServiceNodeComponent from './nodes/ServiceNode';
import EmptyCanvasNode from './nodes/EmptyCanvasNode';
import AnimatedEdge from './edges/AnimatedEdge';
import CanvasContextMenu from './CanvasContextMenu';
const nodeTypes = {
service: ServiceNodeComponent,
empty: EmptyCanvasNode,
};
const edgeTypes = {
animated: AnimatedEdge,
};
function CanvasContent() {
const containerRef = useRef<HTMLDivElement>(null);
const [isOverUI, setIsOverUI] = useState(false);
const { resolvedTheme } = useTheme();
const {
nodes,
edges,
setNodes,
setEdges,
onConnect,
setSelectedNode,
} = useCanvasStore();
const [internalNodes, setInternalNodes, onNodesChange] = useNodesState(nodes);
const [internalEdges, setInternalEdges, onEdgesChange] = useEdgesState(edges);
// Sync internal state with store
React.useEffect(() => {
setInternalNodes(nodes);
}, [nodes, setInternalNodes]);
React.useEffect(() => {
setInternalEdges(edges);
}, [edges, setInternalEdges]);
// Global hover detection for UI elements
useEffect(() => {
const handleMouseEnter = (e: MouseEvent) => {
const target = e.target as Element;
// Check if hovering over any UI element that should disable canvas interactions
if (target && target.closest && (
target.closest('[data-ui-element="true"]') ||
target.closest('.react-flow__node') ||
target.closest('[role="menu"]') ||
target.closest('[role="dialog"]') ||
target.closest('[data-cmdk-list]'))) {
setIsOverUI(true);
}
};
const handleMouseLeave = (e: MouseEvent) => {
const target = e.target as Element;
// Check if leaving UI elements
if (target && target.closest && (
target.closest('[data-ui-element="true"]') ||
target.closest('.react-flow__node') ||
target.closest('[role="menu"]') ||
target.closest('[role="dialog"]') ||
target.closest('[data-cmdk-list]'))) {
setIsOverUI(false);
}
};
const handleGlobalMouseMove = (e: MouseEvent) => {
const target = e.target as Element;
// Check if currently over any UI element
if (target && target.closest) {
const overUI = target.closest('[data-ui-element="true"]') ||
target.closest('.react-flow__node') ||
target.closest('[role="menu"]') ||
target.closest('[role="dialog"]') ||
target.closest('[data-cmdk-list]');
setIsOverUI(!!overUI);
// Debug logging
if (overUI) {
console.log('Over UI element:', overUI);
}
}
};
// Prevent wheel events at document level when over UI
const handleDocumentWheel = (e: WheelEvent) => {
if (isOverUI) {
// When hovering over UI elements, prevent canvas zoom/scroll
// regardless of what the wheel event target is
console.log('Preventing canvas wheel event while over UI');
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return false;
}
};
document.addEventListener('mouseenter', handleMouseEnter, true);
document.addEventListener('mouseleave', handleMouseLeave, true);
document.addEventListener('mousemove', handleGlobalMouseMove, true);
document.addEventListener('wheel', handleDocumentWheel, { passive: false, capture: true });
return () => {
document.removeEventListener('mouseenter', handleMouseEnter, true);
document.removeEventListener('mouseleave', handleMouseLeave, true);
document.removeEventListener('mousemove', handleGlobalMouseMove, true);
document.removeEventListener('wheel', handleDocumentWheel, { capture: true } as any);
};
}, [isOverUI]);
// Ensure container has proper dimensions
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current && containerRef.current.parentElement) {
const parent = containerRef.current.parentElement;
const rect = parent.getBoundingClientRect();
// Only set dimensions if they're valid and different from current
if (rect.width > 0 && rect.height > 0) {
const currentWidth = containerRef.current.style.width;
const currentHeight = containerRef.current.style.height;
const newWidth = `${rect.width}px`;
const newHeight = `${rect.height}px`;
if (currentWidth !== newWidth || currentHeight !== newHeight) {
containerRef.current.style.width = newWidth;
containerRef.current.style.height = newHeight;
}
} else {
// Fallback dimensions if parent has no size
containerRef.current.style.width = '100vw';
containerRef.current.style.height = 'calc(100vh - 56px)';
}
}
};
// Set dimensions immediately
updateDimensions();
// Also try after a short delay
const timeout1 = setTimeout(updateDimensions, 10);
const timeout2 = setTimeout(updateDimensions, 100);
// Use ResizeObserver for reliable dimension tracking
let resizeObserver: ResizeObserver | null = null;
if (containerRef.current?.parentElement && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(updateDimensions);
resizeObserver.observe(containerRef.current.parentElement);
}
return () => {
clearTimeout(timeout1);
clearTimeout(timeout2);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, []);
const handleNodesChange = useCallback((changes: any) => {
onNodesChange(changes);
setNodes(internalNodes);
}, [onNodesChange, setNodes, internalNodes]);
const handleEdgesChange = useCallback((changes: any) => {
onEdgesChange(changes);
setEdges(internalEdges);
}, [onEdgesChange, setEdges, internalEdges]);
const onNodeDragStop = useCallback(
(_event: React.MouseEvent, node: ReactFlowNode) => {
console.log('Node moved:', node.id, node.position);
setNodes(internalNodes);
},
[setNodes, internalNodes]
);
const onNodeClick = useCallback((_event: React.MouseEvent, node: ReactFlowNode) => {
setSelectedNode(node.id);
}, [setSelectedNode]);
const handleCanvasWheel = useCallback((e: React.WheelEvent) => {
console.log('Canvas wheel event, isOverUI:', isOverUI);
if (isOverUI) {
console.log('Preventing canvas zoom/scroll');
e.stopPropagation();
e.preventDefault();
}
}, [isOverUI]);
return (
<div className="flex-1 min-h-0 relative scrollbar-hide">
<div className="w-full h-full bg-background rounded-t-xl border border-[rgb(var(--border))] border-b-0 overflow-hidden">
<CanvasContextMenu>
<div
ref={containerRef}
className="w-full h-full"
style={{ width: '100vw', height: 'calc(100vh - 56px)' }}
>
<ReactFlow
nodes={internalNodes}
edges={internalEdges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeDragStop={onNodeDragStop}
onNodeClick={onNodeClick}
onWheel={handleCanvasWheel}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.2, minZoom: 0.5, maxZoom: 1 }}
defaultViewport={{ x: 0, y: 0, zoom: 0.9 }}
attributionPosition="bottom-left"
className="w-full h-full"
style={{ width: '100%', height: '100%' }}
>
<Background
color={resolvedTheme === 'dark' ? '#545260' : '#878593'}
gap={16}
/>
<Controls
className="bg-background border border-[rgb(var(--border))]"
/>
<MiniMap
nodeColor={(node) => {
switch (node.data?.type) {
case 'github': return '#52297A';
case 'database': return '#181622';
case 'docker': return '#211F2D';
case 'function': return '#545260';
case 'bucket': return '#878593';
default: return '#33323E';
}
}}
className="bg-card border border-[rgb(var(--border))]"
/>
</ReactFlow>
</div>
</CanvasContextMenu>
</div>
</div>
);
}
export default function Canvas() {
return (
<ReactFlowProvider>
<CanvasContent />
</ReactFlowProvider>
);
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
interface CanvasContextMenuProps {
children: React.ReactNode;
}
export default function CanvasContextMenu({ children }: CanvasContextMenuProps): import("react/jsx-runtime").JSX.Element;
export {};
+72
View File
@@ -0,0 +1,72 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React from 'react';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from '@radix-ui/react-context-menu';
import { Github, Database, Container, Code, HardDrive } from 'lucide-react';
import { useCanvasStore } from '../store/canvasStore';
const serviceOptions = [
{
id: 'github',
name: 'GitHub Repository',
type: 'github',
icon: Github,
},
{
id: 'postgres',
name: 'PostgreSQL',
type: 'database',
icon: Database,
},
{
id: 'redis',
name: 'Redis',
type: 'database',
icon: Database,
},
{
id: 'docker',
name: 'Docker Image',
type: 'docker',
icon: Container,
},
{
id: 'function',
name: 'Serverless Function',
type: 'function',
icon: Code,
},
{
id: 'bucket',
name: 'Storage Bucket',
type: 'bucket',
icon: HardDrive,
},
];
export default function CanvasContextMenu({ children }) {
const { addNode } = useCanvasStore();
const handleSelect = (option, event) => {
// Get click position relative to the canvas
const reactFlowElement = event.target.closest('.react-flow');
if (!reactFlowElement)
return;
const rect = reactFlowElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Generate a unique ID for the new node
const nodeId = `${option.type}-${Date.now()}`;
// Create the new node at click position
const newNode = {
id: nodeId,
type: option.type,
position: { x, y },
data: {
label: option.name,
type: option.type,
status: 'stopped',
...(option.type === 'github' && { repo: 'user/repo' }),
},
};
// Add the node to the store
addNode(newNode);
};
return (_jsxs(ContextMenu, { children: [_jsx(ContextMenuTrigger, { className: "w-full h-full", children: children }), _jsxs(ContextMenuContent, { className: "z-50 min-w-[200px] bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden", "data-ui-element": "true", children: [_jsx("div", { className: "px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900", children: _jsx("div", { className: "text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide", children: "Add Service" }) }), _jsx("div", { className: "py-1", children: serviceOptions.map((option) => (_jsxs(ContextMenuItem, { className: "flex items-center px-3 py-2 text-sm cursor-pointer transition-all duration-150 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-gray-700 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none focus:bg-blue-50 dark:focus:bg-gray-700 group", onSelect: (event) => handleSelect(option, event), children: [_jsx("div", { className: "w-5 h-5 rounded bg-gray-100 dark:bg-gray-600 flex items-center justify-center mr-2 group-hover:bg-blue-100 dark:group-hover:bg-blue-900 transition-colors flex-shrink-0", children: _jsx(option.icon, { className: "w-2.5 h-2.5 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" }) }), _jsx("div", { className: "flex-1 font-medium text-sm truncate", children: option.name })] }, option.id))) })] })] }));
}
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import CanvasContextMenu from './CanvasContextMenu';
import { useCanvasStore } from '../store/canvasStore';
vi.mock('../store/canvasStore', () => ({
useCanvasStore: vi.fn(),
}));
const mockUseCanvasStore = vi.mocked(useCanvasStore);
const mockAddNode = vi.fn();
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('CanvasContextMenu', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseCanvasStore.mockReturnValue({
addNode: mockAddNode,
nodes: [],
edges: [],
selectedNode: null,
isCommandPaletteOpen: false,
sidebarOpen: true,
setNodes: vi.fn(),
setEdges: vi.fn(),
setSelectedNode: vi.fn(),
setCommandPaletteOpen: vi.fn(),
setSidebarOpen: vi.fn(),
removeNode: vi.fn(),
});
});
describe('rendering', () => {
it('renders children as the trigger', () => {
render(
<CanvasContextMenu>
<div data-testid="child">Canvas Area</div>
</CanvasContextMenu>,
{ wrapper: createWrapper() }
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.getByText('Canvas Area')).toBeInTheDocument();
});
it('renders context menu with canvas content', () => {
render(
<CanvasContextMenu>
<div>Canvas</div>
</CanvasContextMenu>,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Canvas')).toBeInTheDocument();
});
});
describe('context menu structure', () => {
it('has proper DOM structure', () => {
const { container } = render(
<CanvasContextMenu>
<div data-testid="canvas">Canvas</div>
</CanvasContextMenu>,
{ wrapper: createWrapper() }
);
expect(container).toBeInTheDocument();
});
});
describe('store integration', () => {
it('calls useCanvasStore to get addNode', () => {
render(
<CanvasContextMenu>
<div>Canvas</div>
</CanvasContextMenu>,
{ wrapper: createWrapper() }
);
expect(mockUseCanvasStore).toHaveBeenCalled();
});
});
describe('node creation patterns', () => {
it('node ID follows pattern type-timestamp', () => {
const type = 'github';
const timestamp = Date.now();
const expectedPattern = new RegExp(`^${type}-\\d+$`);
const nodeId = `${type}-${timestamp}`;
expect(nodeId).toMatch(expectedPattern);
});
it('node data includes required fields', () => {
const mockNode = {
id: 'github-123',
type: 'github',
position: { x: 100, y: 100 },
data: {
label: 'GitHub Repository',
type: 'github',
status: 'stopped',
repo: 'user/repo',
},
};
expect(mockNode.data).toHaveProperty('label');
expect(mockNode.data).toHaveProperty('type');
expect(mockNode.data).toHaveProperty('status');
});
it('github type includes repo field', () => {
const githubNode = {
type: 'github',
data: { label: 'GitHub Repository', type: 'github', status: 'stopped', repo: 'user/repo' },
};
expect(githubNode.data).toHaveProperty('repo');
});
it('non-github types do not include repo field', () => {
const dockerNode = {
type: 'docker',
data: { label: 'Docker Image', type: 'docker', status: 'stopped' },
};
expect(dockerNode.data).not.toHaveProperty('repo');
});
});
describe('position calculation', () => {
it('calculates position relative to canvas element', () => {
const clientX = 200;
const clientY = 150;
const rectLeft = 50;
const rectTop = 30;
const x = clientX - rectLeft;
const y = clientY - rectTop;
expect(x).toBe(150);
expect(y).toBe(120);
});
});
});
+6
View File
@@ -0,0 +1,6 @@
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
}
export default function CommandPalette({ open, onClose }: CommandPaletteProps): import("react/jsx-runtime").JSX.Element | null;
export {};
+106
View File
@@ -0,0 +1,106 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React, { useEffect, useState } from 'react';
import { Command } from 'cmdk';
import { Github, Database, Container, Code, HardDrive, Plus, Search, Layers, Server } from 'lucide-react';
import { cn } from '../lib/utils';
import { useCanvasStore } from '../store/canvasStore';
const serviceOptions = [
{
id: 'github',
name: 'GitHub Repository',
description: 'Deploy from a GitHub repository',
icon: Github,
type: 'github',
gradient: 'from-violet-500/10 to-violet-500/5',
},
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Add a PostgreSQL database',
icon: Database,
type: 'database',
gradient: 'from-blue-500/10 to-blue-500/5',
},
{
id: 'redis',
name: 'Redis',
description: 'Add a Redis cache',
icon: Database,
type: 'database',
gradient: 'from-red-500/10 to-red-500/5',
},
{
id: 'docker',
name: 'Docker Image',
description: 'Deploy a Docker image',
icon: Container,
type: 'docker',
gradient: 'from-cyan-500/10 to-cyan-500/5',
},
{
id: 'function',
name: 'Serverless Function',
description: 'Add a serverless function',
icon: Code,
type: 'function',
gradient: 'from-amber-500/10 to-amber-500/5',
},
{
id: 'bucket',
name: 'Storage Bucket',
description: 'Add object storage',
icon: HardDrive,
type: 'bucket',
gradient: 'from-emerald-500/10 to-emerald-500/5',
},
];
const quickActions = [
{ name: 'New Project', icon: Layers, shortcut: 'P' },
{ name: 'Add Server', icon: Server, shortcut: 'S' },
];
export default function CommandPalette({ open, onClose }) {
const [search, setSearch] = useState('');
const { addNode } = useCanvasStore();
useEffect(() => {
const down = (e) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onClose();
}
if (e.key === 'Escape') {
onClose();
}
};
if (open) {
document.addEventListener('keydown', down);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', down);
document.body.style.overflow = '';
};
}, [open, onClose]);
const handleSelect = (option) => {
const nodeId = `${option.type}-${Date.now()}`;
const position = {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
};
const newNode = {
id: nodeId,
type: option.type,
position,
data: {
label: option.name,
type: option.type,
status: 'stopped',
...(option.type === 'github' && { repo: 'user/repo' }),
},
};
addNode(newNode);
onClose();
};
if (!open)
return null;
return (_jsx("div", { className: "fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-start justify-center pt-[15vh] p-4 animate-fade-in", children: _jsx("div", { className: "w-full max-w-xl animate-command-in", children: _jsxs(Command, { className: "bg-card/95 backdrop-blur-2xl rounded-2xl shadow-modal border border-border/50 overflow-hidden", children: [_jsxs("div", { className: "flex items-center border-b border-border/50 px-4 py-3", children: [_jsx(Search, { className: "w-5 h-5 text-muted-foreground mr-3 shrink-0" }), _jsx(Command.Input, { placeholder: "What would you like to create?", value: search, onValueChange: setSearch, className: "flex-1 py-2 bg-transparent outline-none text-foreground placeholder-muted-foreground text-sm", autoFocus: true }), _jsx("kbd", { className: "ml-3 px-2 py-1 text-[10px] bg-muted/50 text-muted-foreground rounded-md font-mono border border-border/50", children: "ESC" })] }), _jsxs(Command.List, { className: "max-h-[350px] overflow-y-auto p-2 scrollbar-thin", children: [_jsx(Command.Empty, { className: "py-10 text-center text-sm text-muted-foreground", children: _jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx(Search, { className: "w-8 h-8 text-muted-foreground/50" }), _jsx("span", { children: "No services found." })] }) }), search === '' && (_jsx("div", { className: "px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider", children: "Quick Actions" })), search === '' && quickActions.map((action) => (_jsxs(Command.Item, { className: cn('flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm cursor-pointer transition-all duration-150', 'hover:bg-muted/50 data-[selected=true]:bg-muted/50', 'text-foreground'), children: [_jsx("div", { className: "w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center", children: _jsx(action.icon, { className: "w-4 h-4 text-muted-foreground" }) }), _jsx("span", { className: "flex-1 font-medium", children: action.name }), _jsxs("kbd", { className: "px-1.5 py-0.5 text-[10px] bg-background text-muted-foreground rounded border border-border/50 font-mono", children: ["\u2318", action.shortcut] })] }, action.name))), _jsx("div", { className: "px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mt-2", children: "Create New Service" }), serviceOptions.map((option) => (_jsxs(Command.Item, { onSelect: () => handleSelect(option), className: cn('flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm cursor-pointer transition-all duration-150', 'hover:bg-muted/50 data-[selected=true]:bg-muted/50', 'text-foreground group'), children: [_jsx("div", { className: cn("w-9 h-9 rounded-xl flex items-center justify-center transition-colors", "bg-gradient-to-br", option.gradient, "group-hover:from-primary/10 group-hover:to-primary/5"), children: _jsx(option.icon, { className: "w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "font-medium", children: option.name }), _jsx("div", { className: "text-xs text-muted-foreground mt-0.5", children: option.description })] }), _jsx(Plus, { className: "w-4 h-4 text-muted-foreground/50 group-hover:text-primary group-hover:text-primary transition-colors" })] }, option.id)))] }), _jsx("div", { className: "border-t border-border/50 px-4 py-3 bg-muted/20", children: _jsxs("div", { className: "flex items-center justify-center text-[11px] text-muted-foreground gap-6", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("kbd", { className: "px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono", children: "\u2191\u2193" }), _jsx("span", { children: "Navigate" })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("kbd", { className: "px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono", children: "\u21B5" }), _jsx("span", { children: "Select" })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("kbd", { className: "px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono", children: "ESC" }), _jsx("span", { children: "Close" })] })] }) })] }) }) }));
}
+215
View File
@@ -0,0 +1,215 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import CommandPalette from './CommandPalette';
import { useCanvasStore } from '../store/canvasStore';
vi.mock('../store/canvasStore', () => ({
useCanvasStore: vi.fn(),
}));
const mockUseCanvasStore = vi.mocked(useCanvasStore);
const mockAddNode = vi.fn();
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('CommandPalette', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseCanvasStore.mockReturnValue({
addNode: mockAddNode,
nodes: [],
edges: [],
selectedNode: null,
isCommandPaletteOpen: false,
sidebarOpen: true,
setNodes: vi.fn(),
setEdges: vi.fn(),
setSelectedNode: vi.fn(),
setCommandPaletteOpen: vi.fn(),
setSidebarOpen: vi.fn(),
removeNode: vi.fn(),
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('does not render when open is false', () => {
render(<CommandPalette open={false} onClose={vi.fn()} />, { wrapper: createWrapper() });
expect(screen.queryByPlaceholderText('What would you like to create?')).not.toBeInTheDocument();
});
it('renders when open is true', () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
expect(screen.getByPlaceholderText('What would you like to create?')).toBeInTheDocument();
});
it('renders all service options', () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
expect(screen.getByText('GitHub Repository')).toBeInTheDocument();
expect(screen.getByText('PostgreSQL')).toBeInTheDocument();
expect(screen.getByText('Redis')).toBeInTheDocument();
expect(screen.getByText('Docker Image')).toBeInTheDocument();
expect(screen.getByText('Serverless Function')).toBeInTheDocument();
expect(screen.getByText('Storage Bucket')).toBeInTheDocument();
});
it('renders quick actions when search is empty', () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
expect(screen.getByText('New Project')).toBeInTheDocument();
expect(screen.getByText('Add Server')).toBeInTheDocument();
});
it('shows keyboard shortcuts hint', () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
expect(screen.getByText('Navigate')).toBeInTheDocument();
expect(screen.getByText('Select')).toBeInTheDocument();
expect(screen.getByText('Close')).toBeInTheDocument();
});
});
describe('keyboard interactions', () => {
it('closes on Escape key', async () => {
const onClose = vi.fn();
render(<CommandPalette open={true} onClose={onClose} />, { wrapper: createWrapper() });
fireEvent.keyDown(document.body, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('closes on Cmd+K', async () => {
const onClose = vi.fn();
render(<CommandPalette open={true} onClose={onClose} />, { wrapper: createWrapper() });
fireEvent.keyDown(document.body, { key: 'k', metaKey: true });
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('closes on Ctrl+K', async () => {
const onClose = vi.fn();
render(<CommandPalette open={true} onClose={onClose} />, { wrapper: createWrapper() });
fireEvent.keyDown(document.body, { key: 'k', ctrlKey: true });
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
describe('search functionality', () => {
it('shows PostgreSQL when searching for postgres', async () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
const input = screen.getByPlaceholderText('What would you like to create?');
await userEvent.type(input, 'postgres');
expect(screen.getByText('PostgreSQL')).toBeInTheDocument();
});
it('shows empty state when no results match', async () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
const input = screen.getByPlaceholderText('What would you like to create?');
await userEvent.type(input, 'nonexistent');
expect(screen.getByText('No services found.')).toBeInTheDocument();
});
it('hides quick actions when searching', async () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
const input = screen.getByPlaceholderText('What would you like to create?');
await userEvent.type(input, 'git');
expect(screen.queryByText('New Project')).not.toBeInTheDocument();
});
});
describe('service selection', () => {
it('adds node when service is selected', async () => {
const onClose = vi.fn();
render(<CommandPalette open={true} onClose={onClose} />, { wrapper: createWrapper() });
const githubOption = screen.getByText('GitHub Repository');
await userEvent.click(githubOption);
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
type: 'github',
data: expect.objectContaining({
label: 'GitHub Repository',
type: 'github',
}),
})
);
expect(onClose).toHaveBeenCalled();
});
it('generates unique node IDs', async () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
const dockerOption = screen.getByText('Docker Image');
await userEvent.click(dockerOption);
const call = mockAddNode.mock.calls[0][0];
expect(call.id).toMatch(/^docker-\d+$/);
});
it('adds repo data for GitHub type', async () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
const githubOption = screen.getByText('GitHub Repository');
await userEvent.click(githubOption);
expect(mockAddNode).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
repo: 'user/repo',
}),
})
);
});
});
describe('body scroll lock', () => {
it('locks body scroll when open', () => {
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
expect(document.body.style.overflow).toBe('hidden');
});
it('restores body scroll when closed', () => {
const { unmount } = render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
unmount();
expect(document.body.style.overflow).toBe('');
});
});
});
+111 -76
View File
@@ -7,7 +7,9 @@ import {
Code, Code,
HardDrive, HardDrive,
Plus, Plus,
Search Search,
Layers,
Server
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { useCanvasStore } from '../store/canvasStore'; import { useCanvasStore } from '../store/canvasStore';
@@ -23,6 +25,7 @@ interface ServiceOption {
description: string; description: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
type: 'github' | 'database' | 'docker' | 'function' | 'bucket'; type: 'github' | 'database' | 'docker' | 'function' | 'bucket';
gradient: string;
} }
const serviceOptions: ServiceOption[] = [ const serviceOptions: ServiceOption[] = [
@@ -32,6 +35,7 @@ const serviceOptions: ServiceOption[] = [
description: 'Deploy from a GitHub repository', description: 'Deploy from a GitHub repository',
icon: Github, icon: Github,
type: 'github', type: 'github',
gradient: 'from-violet-500/10 to-violet-500/5',
}, },
{ {
id: 'postgres', id: 'postgres',
@@ -39,6 +43,7 @@ const serviceOptions: ServiceOption[] = [
description: 'Add a PostgreSQL database', description: 'Add a PostgreSQL database',
icon: Database, icon: Database,
type: 'database', type: 'database',
gradient: 'from-blue-500/10 to-blue-500/5',
}, },
{ {
id: 'redis', id: 'redis',
@@ -46,6 +51,7 @@ const serviceOptions: ServiceOption[] = [
description: 'Add a Redis cache', description: 'Add a Redis cache',
icon: Database, icon: Database,
type: 'database', type: 'database',
gradient: 'from-red-500/10 to-red-500/5',
}, },
{ {
id: 'docker', id: 'docker',
@@ -53,6 +59,7 @@ const serviceOptions: ServiceOption[] = [
description: 'Deploy a Docker image', description: 'Deploy a Docker image',
icon: Container, icon: Container,
type: 'docker', type: 'docker',
gradient: 'from-cyan-500/10 to-cyan-500/5',
}, },
{ {
id: 'function', id: 'function',
@@ -60,6 +67,7 @@ const serviceOptions: ServiceOption[] = [
description: 'Add a serverless function', description: 'Add a serverless function',
icon: Code, icon: Code,
type: 'function', type: 'function',
gradient: 'from-amber-500/10 to-amber-500/5',
}, },
{ {
id: 'bucket', id: 'bucket',
@@ -67,9 +75,15 @@ const serviceOptions: ServiceOption[] = [
description: 'Add object storage', description: 'Add object storage',
icon: HardDrive, icon: HardDrive,
type: 'bucket', type: 'bucket',
gradient: 'from-emerald-500/10 to-emerald-500/5',
}, },
]; ];
const quickActions = [
{ name: 'New Project', icon: Layers, shortcut: 'P' },
{ name: 'Add Server', icon: Server, shortcut: 'S' },
];
export default function CommandPalette({ open, onClose }: CommandPaletteProps) { export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { addNode } = useCanvasStore(); const { addNode } = useCanvasStore();
@@ -87,24 +101,23 @@ export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
if (open) { if (open) {
document.addEventListener('keydown', down); document.addEventListener('keydown', down);
document.body.style.overflow = 'hidden';
} }
return () => { return () => {
document.removeEventListener('keydown', down); document.removeEventListener('keydown', down);
document.body.style.overflow = '';
}; };
}, [open, onClose]); }, [open, onClose]);
const handleSelect = (option: ServiceOption) => { const handleSelect = (option: ServiceOption) => {
// Generate a unique ID for the new node
const nodeId = `${option.type}-${Date.now()}`; const nodeId = `${option.type}-${Date.now()}`;
// Calculate a random position for the new node
const position = { const position = {
x: Math.random() * 400 + 100, x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100, y: Math.random() * 300 + 100,
}; };
// Create the new node
const newNode = { const newNode = {
id: nodeId, id: nodeId,
type: option.type, type: option.type,
@@ -117,91 +130,113 @@ export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
}, },
}; };
// Add the node to the store
addNode(newNode); addNode(newNode);
console.log('Added service:', option);
onClose(); onClose();
}; };
if (!open) return null; if (!open) return null;
return ( return (
<div <div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-start justify-center pt-[15vh] p-4 animate-fade-in">
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4" <div className="w-full max-w-xl animate-command-in">
data-ui-element="true" <Command className="bg-card/95 backdrop-blur-2xl rounded-2xl shadow-modal border border-border/50 overflow-hidden">
> <div className="flex items-center border-b border-border/50 px-4 py-3">
<div className="w-full max-w-md"> <Search className="w-5 h-5 text-muted-foreground mr-3 shrink-0" />
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden"> <Command.Input
<Command className="rounded-2xl"> placeholder="What would you like to create?"
<div className="flex items-center border-b border-gray-200 dark:border-gray-700 px-4 py-3"> value={search}
<Search className="w-5 h-5 text-gray-400 mr-3" /> onValueChange={setSearch}
<Command.Input className="flex-1 py-2 bg-transparent outline-none text-foreground placeholder-muted-foreground text-sm"
placeholder="What would you like to create?" autoFocus
value={search} />
onValueChange={setSearch} <kbd className="ml-3 px-2 py-1 text-[10px] bg-muted/50 text-muted-foreground rounded-md font-mono border border-border/50">
className="flex-1 py-2 bg-transparent outline-none text-gray-900 dark:text-gray-100 placeholder-gray-500 text-sm" ESC
/> </kbd>
<kbd className="ml-3 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded"> </div>
ESC
</kbd> <Command.List className="max-h-[350px] overflow-y-auto p-2 scrollbar-thin">
<Command.Empty className="py-10 text-center text-sm text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Search className="w-8 h-8 text-muted-foreground/50" />
<span>No services found.</span>
</div>
</Command.Empty>
{search === '' && (
<div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Quick Actions
</div>
)}
{search === '' && quickActions.map((action) => (
<Command.Item
key={action.name}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm cursor-pointer transition-all duration-150',
'hover:bg-muted/50 data-[selected=true]:bg-muted/50',
'text-foreground'
)}
>
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
<action.icon className="w-4 h-4 text-muted-foreground" />
</div>
<span className="flex-1 font-medium">{action.name}</span>
<kbd className="px-1.5 py-0.5 text-[10px] bg-background text-muted-foreground rounded border border-border/50 font-mono">
{action.shortcut}
</kbd>
</Command.Item>
))}
<div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mt-2">
Create New Service
</div> </div>
<Command.List className="max-h-[350px] overflow-y-auto p-2 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent"> {serviceOptions.map((option) => (
<Command.Empty className="py-8 text-center text-sm text-gray-500 dark:text-gray-400"> <Command.Item
No services found. key={option.id}
</Command.Empty> onSelect={() => handleSelect(option)}
className={cn(
{serviceOptions.map((option) => ( 'flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm cursor-pointer transition-all duration-150',
<Command.Item 'hover:bg-muted/50 data-[selected=true]:bg-muted/50',
key={option.id} 'text-foreground group'
onSelect={() => handleSelect(option)} )}
className={cn( >
'flex items-center gap-3 px-3 py-3 rounded-xl text-sm cursor-pointer transition-all duration-150', <div className={cn(
'hover:bg-blue-50 dark:hover:bg-gray-700', "w-9 h-9 rounded-xl flex items-center justify-center transition-colors",
'focus:bg-blue-50 dark:focus:bg-gray-700', "bg-gradient-to-br",
'text-gray-700 dark:text-gray-300', option.gradient,
'hover:text-blue-600 dark:hover:text-blue-400' "group-hover:from-primary/10 group-hover:to-primary/5"
)} )}>
> <option.icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-600 flex items-center justify-center group-hover:bg-blue-100 dark:group-hover:bg-blue-900 transition-colors flex-shrink-0"> </div>
<option.icon className="w-4 h-4 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" /> <div className="flex-1 min-w-0">
<div className="font-medium">{option.name}</div>
<div className="text-xs text-muted-foreground mt-0.5">
{option.description}
</div> </div>
<div className="flex-1 min-w-0"> </div>
<div className="font-medium">{option.name}</div> <Plus className="w-4 h-4 text-muted-foreground/50 group-hover:text-primary group-hover:text-primary transition-colors" />
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> </Command.Item>
{option.description} ))}
</div> </Command.List>
</div>
<Plus className="w-4 h-4 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
</Command.Item>
))}
</Command.List>
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-900"> <div className="border-t border-border/50 px-4 py-3 bg-muted/20">
<div className="flex items-center justify-center text-xs text-gray-500 dark:text-gray-400 gap-6"> <div className="flex items-center justify-center text-[11px] text-muted-foreground gap-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<kbd className="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs"> <kbd className="px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono"></kbd>
<span>Navigate</span>
</kbd> </div>
<span>Navigate</span> <div className="flex items-center gap-1.5">
</div> <kbd className="px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono"></kbd>
<div className="flex items-center gap-2"> <span>Select</span>
<kbd className="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs"> </div>
<div className="flex items-center gap-1.5">
</kbd> <kbd className="px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono">ESC</kbd>
<span>Select</span> <span>Close</span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs">
ESC
</kbd>
<span>Close</span>
</div>
</div> </div>
</div> </div>
</Command> </div>
</div> </Command>
</div> </div>
</div> </div>
); );
+1
View File
@@ -0,0 +1 @@
export default function Layout(): import("react/jsx-runtime").JSX.Element;
File diff suppressed because one or more lines are too long
+244
View File
@@ -0,0 +1,244 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import Layout from './Layout';
vi.mock('../store/canvasStore', () => ({
useCanvasStore: vi.fn(),
}));
vi.mock('../hooks/useAuth', () => ({
useAuth: vi.fn(),
}));
vi.mock('./CommandPalette', () => ({
default: ({ open, onClose }: { open: boolean; onClose: () => void }) => (
open ? <div data-testid="command-palette">Command Palette</div> : null
),
}));
import { useCanvasStore } from '../store/canvasStore';
import { useAuth } from '../hooks/useAuth';
const mockUseCanvasStore = vi.mocked(useCanvasStore);
const mockUseAuth = vi.mocked(useAuth);
const mockSetCommandPaletteOpen = vi.fn();
const mockSetSidebarOpen = vi.fn();
const mockLogout = vi.fn();
const mockNavigate = vi.fn();
function createWrapper(initialRoute = '/') {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>
{children}
</MemoryRouter>
</QueryClientProvider>
);
}
const createMockStore = () => ({
isCommandPaletteOpen: false,
setCommandPaletteOpen: mockSetCommandPaletteOpen,
sidebarOpen: true,
setSidebarOpen: mockSetSidebarOpen,
addNode: vi.fn(),
nodes: [],
edges: [],
selectedNode: null,
setNodes: vi.fn(),
setEdges: vi.fn(),
setSelectedNode: vi.fn(),
removeNode: vi.fn(),
});
const createMockAuth = (overrides = {}) => ({
user: { id: '1', name: 'Test User', email: 'test@example.com', created_at: '', updated_at: '' },
isLoading: false,
isAuthenticated: true,
login: vi.fn(),
register: vi.fn(),
logout: mockLogout,
...overrides,
});
describe('Layout', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseCanvasStore.mockReturnValue(createMockStore());
mockUseAuth.mockReturnValue(createMockAuth());
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
});
describe('rendering', () => {
it('renders sidebar with logo', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getAllByText('Containr').length).toBeGreaterThan(0);
expect(screen.getAllByText('Self-hosted PaaS').length).toBeGreaterThan(0);
});
it('renders navigation items', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getAllByText('Dashboard').length).toBeGreaterThan(0);
expect(screen.getByText('Projects')).toBeInTheDocument();
expect(screen.getByText('Analytics')).toBeInTheDocument();
expect(screen.getByText('Git Integration')).toBeInTheDocument();
expect(screen.getByText('Infrastructure')).toBeInTheDocument();
expect(screen.getAllByText('Settings').length).toBeGreaterThan(0);
});
it('renders user avatar with initials', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByText('T')).toBeInTheDocument();
});
it('renders documentation section', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByText('Documentation')).toBeInTheDocument();
expect(screen.getByText('Learn how to deploy your first service')).toBeInTheDocument();
});
it('renders quick search button', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByText('Quick Search')).toBeInTheDocument();
});
it('renders new deployment button', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getAllByText('New Deployment').length).toBeGreaterThan(0);
});
});
describe('page title', () => {
it('shows Dashboard as default title', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument();
});
it('shows Projects title on projects page', () => {
render(<Layout />, { wrapper: createWrapper('/projects') });
expect(screen.getByRole('heading', { name: 'Projects' })).toBeInTheDocument();
});
it('shows Analytics title on analytics page', () => {
render(<Layout />, { wrapper: createWrapper('/analytics') });
expect(screen.getByRole('heading', { name: 'Analytics' })).toBeInTheDocument();
});
});
describe('status badge', () => {
it('shows operational status on dashboard', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByText('All systems operational')).toBeInTheDocument();
});
it('hides operational status on other pages', () => {
render(<Layout />, { wrapper: createWrapper('/projects') });
expect(screen.queryByText('All systems operational')).not.toBeInTheDocument();
});
});
describe('command palette', () => {
it('opens command palette when quick search clicked', () => {
render(<Layout />, { wrapper: createWrapper() });
const quickSearchButton = screen.getByText('Quick Search');
fireEvent.click(quickSearchButton);
expect(mockSetCommandPaletteOpen).toHaveBeenCalledWith(true);
});
it('opens command palette when new deployment clicked', () => {
render(<Layout />, { wrapper: createWrapper() });
const newDeploymentButton = screen.getByText('New Deployment');
fireEvent.click(newDeploymentButton);
expect(mockSetCommandPaletteOpen).toHaveBeenCalledWith(true);
});
});
describe('sidebar toggle', () => {
it('toggles sidebar when menu button clicked', () => {
render(<Layout />, { wrapper: createWrapper() });
const toggleButtons = screen.getAllByRole('button').filter(btn => {
const svg = btn.querySelector('svg');
return svg && (svg.classList.contains('lucide-x') || svg.classList.contains('lucide-menu'));
});
expect(toggleButtons.length).toBeGreaterThan(0);
});
});
describe('user dropdown', () => {
it('displays user avatar in header', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByText('T')).toBeInTheDocument();
});
it('displays fallback when user has no name', () => {
mockUseAuth.mockReturnValue(createMockAuth({
user: { id: '1', name: '', email: 'user@test.com', created_at: '', updated_at: '' },
}));
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByText('u')).toBeInTheDocument();
});
});
describe('navigation badges', () => {
it('shows count badges on navigation items', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
it('shows Beta badge on Canvas', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByText('Beta')).toBeInTheDocument();
});
});
describe('section grouping', () => {
it('renders navigation sections', () => {
render(<Layout />, { wrapper: createWrapper() });
expect(screen.getByText('Overview')).toBeInTheDocument();
expect(screen.getByText('Build')).toBeInTheDocument();
expect(screen.getByText('Deploy')).toBeInTheDocument();
expect(screen.getByText('Resources')).toBeInTheDocument();
expect(screen.getAllByText('Security').length).toBeGreaterThan(0);
});
});
});
+317 -110
View File
@@ -1,188 +1,395 @@
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
import { Plus, Activity, Settings, GitBranch, Database, Menu, X, Server, Folder, Github, Cpu, BarChart3 } from 'lucide-react'; import { useState } from 'react';
import {
Plus,
Activity,
Settings,
Database,
Menu,
X,
Server,
Folder,
Github,
Cpu,
BarChart3,
LogOut,
ChevronDown,
Search,
Zap,
Layers,
Sparkles,
Bell,
Rocket,
Workflow,
Shield,
ChevronRight,
ExternalLink,
Building2,
BookOpen
} from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { ThemeToggle } from './ui/theme-toggle'; import { ThemeToggle } from './ui/theme-toggle';
import { Separator } from './ui/separator';
import { Badge } from './ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import CommandPalette from './CommandPalette'; import CommandPalette from './CommandPalette';
import { useCanvasStore } from '../store/canvasStore'; import { useCanvasStore } from '../store/canvasStore';
import { useAuth } from '../hooks/useAuth';
import { cn } from '../lib/utils';
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/', icon: Activity }, { name: 'Dashboard', href: '/', icon: Activity, section: 'overview', badge: null },
{ name: 'Projects', href: '/projects', icon: Folder }, { name: 'Projects', href: '/projects', icon: Folder, section: 'overview', badge: '12' },
{ name: 'Analytics', href: '/analytics', icon: BarChart3 }, { name: 'Analytics', href: '/analytics', icon: BarChart3, section: 'overview', badge: null },
{ name: 'Git Integration', href: '/git', icon: Github }, { name: 'Canvas', href: '/canvas', icon: Workflow, section: 'build', badge: 'Beta' },
{ name: 'Infrastructure', href: '/infrastructure', icon: Server }, { name: 'Git Integration', href: '/git', icon: Github, section: 'deploy', badge: null },
{ name: 'Node Agents', href: '/agents', icon: Cpu }, { name: 'Infrastructure', href: '/infrastructure', icon: Server, section: 'deploy', badge: '3' },
{ name: 'Deployments', href: '/deployments', icon: GitBranch }, { name: 'Node Agents', href: '/agents', icon: Cpu, section: 'deploy', badge: null },
{ name: 'Databases', href: '/databases', icon: Database }, { name: 'Databases', href: '/databases', icon: Database, section: 'resources', badge: '5' },
{ name: 'Settings', href: '/settings', icon: Settings }, { name: 'Security', href: '/security', icon: Shield, section: 'security', badge: null },
{ name: 'Settings', href: '/settings', icon: Settings, section: 'settings', badge: null },
]; ];
const sections = {
overview: { label: 'Overview', icon: Layers, color: 'text-blue-500', bg: 'bg-blue-500/10' },
build: { label: 'Build', icon: Rocket, color: 'text-violet-500', bg: 'bg-violet-500/10' },
deploy: { label: 'Deploy', icon: Zap, color: 'text-amber-500', bg: 'bg-amber-500/10' },
resources: { label: 'Resources', icon: Database, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
security: { label: 'Security', icon: Shield, color: 'text-red-500', bg: 'bg-red-500/10' },
settings: { label: 'Settings', icon: Settings, color: 'text-muted-foreground', bg: 'bg-muted/50' },
};
export default function Layout() { export default function Layout() {
const { isCommandPaletteOpen, setCommandPaletteOpen, sidebarOpen, setSidebarOpen } = useCanvasStore(); const { isCommandPaletteOpen, setCommandPaletteOpen, sidebarOpen, setSidebarOpen } = useCanvasStore();
const { user, logout } = useAuth();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const getPageTitle = () => { const getPageTitle = () => {
const currentNav = navigation.find(item => item.href === location.pathname); const currentNav = navigation.find(item => item.href === location.pathname);
return currentNav ? currentNav.name : 'Dashboard'; return currentNav ? currentNav.name : 'Dashboard';
}; };
const handleLogout = () => {
logout();
navigate('/login');
};
const groupedNavigation = navigation.reduce((acc, item) => {
if (!acc[item.section]) acc[item.section] = [];
acc[item.section].push(item);
return acc;
}, {} as Record<string, typeof navigation>);
const NavLink = ({ item, mobile = false }: { item: typeof navigation[0]; mobile?: boolean }) => {
const isActive = location.pathname === item.href;
const isHovered = hoveredItem === item.name;
return (
<Link
to={item.href}
onMouseEnter={() => setHoveredItem(item.name)}
onMouseLeave={() => setHoveredItem(null)}
className={cn(
"group relative flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-300",
isActive
? "text-primary bg-primary/10 dark:bg-primary/15 shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
mobile && "text-base py-3"
)}
>
<div className={cn(
"relative p-1.5 rounded-lg transition-all duration-300",
isActive ? "bg-primary/15" : isHovered ? "bg-muted/70" : "bg-transparent"
)}>
<item.icon className={cn(
"w-[18px] h-[18px] shrink-0 transition-all duration-300",
isActive ? "text-primary" : isHovered ? "text-foreground" : "text-muted-foreground"
)} />
{isActive && (
<div className="absolute inset-0 rounded-lg bg-primary/10 animate-pulse" />
)}
</div>
<span className="relative z-10">{item.name}</span>
{item.badge && (
<Badge
variant="outline"
className={cn(
"ml-auto text-[9px] font-semibold px-1.5 py-0 h-4",
item.badge === 'New' || item.badge === 'Beta'
? "bg-violet-500/10 text-violet-500 border-violet-500/20"
: "bg-muted/50 text-muted-foreground border-border/50"
)}
>
{item.badge}
</Badge>
)}
{isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-5 bg-gradient-to-b from-primary to-primary/50 rounded-r-full" />
)}
<ChevronRight className={cn(
"w-4 h-4 ml-auto opacity-0 -translate-x-2 transition-all duration-300",
isHovered && "opacity-100 translate-x-0"
)} />
</Link>
);
};
return ( return (
<div className="h-screen w-full flex bg-background overflow-hidden"> <div className="h-screen w-full flex bg-background overflow-hidden">
{/* Desktop Sidebar */} <div className="fixed inset-0 -z-10">
<div <div className="absolute inset-0 dot-grid opacity-30 dark:opacity-15" />
className={` <div className="absolute top-0 right-0 w-[600px] h-[600px] bg-gradient-radial from-primary/10 via-transparent to-transparent blur-3xl" />
${sidebarOpen ? 'w-64' : 'w-0'} <div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-gradient-radial from-violet-500/10 via-transparent to-transparent blur-3xl" />
transition-all duration-300 ease-in-out <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-gradient-radial from-primary/5 via-transparent to-transparent blur-3xl" />
bg-card border-r border-[rgb(var(--border))] <div className="absolute inset-0 mesh-gradient opacity-50" />
flex flex-col overflow-hidden flex-shrink-0 </div>
hidden lg:flex
`} <aside
className={cn(
"hidden lg:flex flex-col border-r border-border/40 bg-card/80 backdrop-blur-xl transition-all duration-300 ease-out flex-shrink-0 relative",
sidebarOpen ? "w-72" : "w-0 opacity-0 -translate-x-full"
)}
> >
{/* Header */} <div className="absolute inset-0 bg-gradient-to-b from-primary/2 via-transparent to-transparent pointer-events-none" />
<div className="p-6 border-b border-[rgb(var(--border))] flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"> <div className="relative p-4 border-b border-border/40">
Containr <Link to="/" className="flex items-center gap-3 group">
</h1> <div className="relative">
<p className="text-sm text-muted-foreground"> <div className="w-10 h-10 bg-gradient-to-br from-primary via-violet-500 to-primary rounded-xl flex items-center justify-center shadow-lg shadow-primary/20 transition-all duration-500 group-hover:shadow-xl group-hover:shadow-primary/30 group-hover:scale-105 p-1.5">
Self-hosted PaaS <img src="/containr.svg" alt="Containr" className="w-full h-full object-contain" />
</p> </div>
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-emerald-500 rounded-full border-2 border-card">
<div className="absolute inset-0 rounded-full bg-emerald-500 animate-ping opacity-75" />
</div>
</div>
<div className="flex flex-col">
<span className="text-lg font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">Containr</span>
<span className="text-[10px] text-muted-foreground leading-none font-medium">Self-hosted PaaS</span>
</div>
</Link>
</div> </div>
{/* Navigation */} <nav className="flex-1 p-3 overflow-y-auto scrollbar-thin">
<nav className="flex-1 p-4 space-y-2 overflow-y-auto scrollbar-hide"> {Object.entries(groupedNavigation).map(([section, items]) => {
{navigation.map((item) => { const sectionConfig = sections[section as keyof typeof sections];
const isActive = location.pathname === item.href;
return ( return (
<Button <div key={section} className="mb-4">
key={item.name} <div className="flex items-center gap-2 px-3 py-2 mb-1">
variant={isActive ? "default" : "ghost"} <div className={cn("p-1 rounded-md", sectionConfig?.bg)}>
className="w-full justify-start gap-3 h-10" {sectionConfig && <sectionConfig.icon className={cn("w-3 h-3", sectionConfig.color)} />}
asChild </div>
> <span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
<Link to={item.href}> {sectionConfig?.label || section}
<item.icon className="w-4 h-4" /> </span>
{item.name} </div>
</Link> <div className="space-y-1">
</Button> {items.map((item) => (
<NavLink key={item.name} item={item} />
))}
</div>
</div>
); );
})} })}
</nav> </nav>
{/* Add Service Button */} <div className="relative p-3 border-t border-border/40 space-y-3">
<div className="p-4 border-t border-[rgb(var(--border))] flex-shrink-0"> <div className="p-3 rounded-xl bg-gradient-to-br from-primary/5 via-violet-500/5 to-transparent border border-primary/10">
<div className="flex items-center gap-2 mb-2">
<BookOpen className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">Documentation</span>
</div>
<p className="text-xs text-muted-foreground mb-3">Learn how to deploy your first service</p>
<Button variant="outline" size="sm" className="w-full h-8 text-xs">
View Docs
<ExternalLink className="w-3 h-3 ml-1.5" />
</Button>
</div>
<button
onClick={() => setCommandPaletteOpen(true)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-muted/30 hover:bg-muted/50 text-muted-foreground hover:text-foreground text-sm transition-all duration-200 group border border-transparent hover:border-border/50"
>
<Search className="w-4 h-4 group-hover:text-primary transition-colors" />
<span>Quick Search</span>
<div className="ml-auto flex items-center gap-1">
<kbd className="px-1.5 py-0.5 text-[10px] bg-background/80 rounded-md border border-border/50 font-mono shadow-sm"></kbd>
<kbd className="px-1.5 py-0.5 text-[10px] bg-background/80 rounded-md border border-border/50 font-mono shadow-sm">K</kbd>
</div>
</button>
<Button <Button
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
className="w-full gap-2" className="w-full gap-2 h-11 rounded-xl bg-gradient-to-r from-primary via-violet-500 to-primary bg-[length:200%_100%] hover:bg-right transition-all duration-500 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30"
size="default"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Service New Deployment
</Button> </Button>
</div> </div>
</div> </aside>
{/* Main Content */}
<div className="flex-1 flex flex-col relative min-w-0"> <div className="flex-1 flex flex-col relative min-w-0">
{/* Top Bar */} <header className="h-16 glass-heavy border-b border-border/40 flex items-center px-4 gap-4 flex-shrink-0 sticky top-0 z-40">
<div className="h-16 bg-card border-b border-[rgb(var(--border))] flex items-center px-4 gap-4 flex-shrink-0">
{/* Mobile Menu */}
<Sheet> <Sheet>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden"> <Button variant="ghost" size="icon" className="lg:hidden hover:bg-muted/50">
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="left" className="w-64 p-0"> <SheetContent side="left" className="w-80 p-0 bg-card/95 backdrop-blur-2xl border-r border-border/40">
<div className="p-6 border-b border-[rgb(var(--border))]"> <div className="p-4 border-b border-border/40">
<h1 className="text-2xl font-bold text-foreground"> <Link to="/" className="flex items-center gap-3">
Containr <div className="w-10 h-10 bg-gradient-to-br from-primary via-violet-500 to-primary rounded-xl flex items-center justify-center shadow-lg shadow-primary/20 p-1.5">
</h1> <img src="/containr.svg" alt="Containr" className="w-full h-full object-contain" />
<p className="text-sm text-muted-foreground"> </div>
Self-hosted PaaS <div className="flex flex-col">
</p> <span className="text-lg font-bold">Containr</span>
<span className="text-[10px] text-muted-foreground leading-none">Self-hosted PaaS</span>
</div>
</Link>
</div> </div>
<nav className="flex-1 p-4 space-y-2"> <nav className="flex-1 p-3 space-y-1">
{navigation.map((item) => { {navigation.map((item) => (
const isActive = location.pathname === item.href; <NavLink key={item.name} item={item} mobile />
return ( ))}
<Button
key={item.name}
variant={isActive ? "default" : "ghost"}
className="w-full justify-start gap-3 h-10"
asChild
>
<Link to={item.href}>
<item.icon className="w-4 h-4" />
{item.name}
</Link>
</Button>
);
})}
</nav> </nav>
<div className="p-4 border-t border-[var(--border)]"> <Separator />
<div className="p-3">
<Button <Button
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
className="w-full gap-2" className="w-full gap-2 h-11 rounded-xl bg-gradient-to-r from-primary to-violet-500"
size="default"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Service New Deployment
</Button> </Button>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{/* Desktop Sidebar Toggle */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setSidebarOpen(!sidebarOpen)} onClick={() => setSidebarOpen(!sidebarOpen)}
className="hidden lg:flex" className="hidden lg:flex hover:bg-muted/50 transition-colors"
> >
{sidebarOpen ? ( {sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
<span className="sr-only">Toggle sidebar</span>
</Button> </Button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-foreground truncate"> <div className="flex items-center gap-2">
{getPageTitle()} <h2 className="text-base font-semibold text-foreground truncate">
</h2> {getPageTitle()}
</h2>
{location.pathname === '/' && (
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1 animate-pulse" />
All systems operational
</Badge>
)}
</div>
</div> </div>
{/* Theme Toggle */} <div className="flex items-center gap-1.5 sm:gap-2">
<ThemeToggle /> <button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 px-3 py-2 rounded-xl bg-muted/30 hover:bg-muted/50 text-muted-foreground hover:text-foreground text-sm transition-all duration-200 group border border-transparent hover:border-border/50"
>
<Search className="w-4 h-4 group-hover:text-primary transition-colors" />
<span className="hidden lg:inline">Search...</span>
<div className="hidden sm:flex items-center gap-1">
<kbd className="px-1.5 py-0.5 text-[10px] bg-background/80 rounded-md border border-border/50 font-mono shadow-sm"></kbd>
<kbd className="px-1.5 py-0.5 text-[10px] bg-background/80 rounded-md border border-border/50 font-mono shadow-sm">K</kbd>
</div>
</button>
{/* User Avatar */} <Button
<Avatar className="h-8 w-8"> size="sm"
<AvatarImage src="/avatars/01.png" alt="User" /> className="hidden sm:flex gap-2 h-9 rounded-xl bg-gradient-to-r from-primary to-violet-500 shadow-md shadow-primary/10 hover:shadow-lg hover:shadow-primary/20"
<AvatarFallback>U</AvatarFallback> onClick={() => setCommandPaletteOpen(true)}
</Avatar> >
</div> <Sparkles className="w-4 h-4" />
<span className="hidden lg:inline">New</span>
</Button>
{/* Page Content */} <Button variant="ghost" size="icon" className="relative hover:bg-muted/50 rounded-xl">
<div className="flex-1 relative min-h-0 overflow-auto"> <Bell className="h-4 w-4" />
<span className="absolute top-1 right-1 w-2 h-2 bg-destructive rounded-full">
<span className="absolute inset-0 rounded-full bg-destructive animate-ping opacity-75" />
</span>
</Button>
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-2 hover:bg-muted/50 transition-colors rounded-xl">
<Avatar className="h-8 w-8 ring-2 ring-primary/20 ring-offset-1 ring-offset-background">
<AvatarImage src="/avatars/01.png" alt="User" />
<AvatarFallback className="bg-gradient-to-br from-primary/30 to-violet-500/30 text-primary font-medium text-sm">
{user?.name?.charAt(0) || user?.email?.charAt(0) || 'U'}
</AvatarFallback>
</Avatar>
<ChevronDown className="w-4 h-4 text-muted-foreground hidden sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60 glass-heavy border-border/40 shadow-xl rounded-xl">
<div className="px-3 py-3">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 ring-2 ring-primary/20">
<AvatarImage src="/avatars/01.png" alt="User" />
<AvatarFallback className="bg-gradient-to-br from-primary/30 to-violet-500/30 text-primary font-medium">
{user?.name?.charAt(0) || user?.email?.charAt(0) || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<p className="text-sm font-semibold">{user?.name || 'User'}</p>
<p className="text-xs text-muted-foreground">{user?.email || 'user@example.com'}</p>
</div>
</div>
</div>
<DropdownMenuSeparator className="bg-border/50" />
<DropdownMenuItem asChild className="cursor-pointer focus:bg-muted/50 rounded-lg mx-1">
<Link to="/settings" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer focus:bg-muted/50 rounded-lg mx-1">
<Building2 className="w-4 h-4 mr-2" />
Organization
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-border/50" />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive cursor-pointer focus:bg-destructive/10 rounded-lg mx-1">
<LogOut className="w-4 h-4 mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 relative min-h-0 overflow-auto bg-background/30">
<Outlet /> <Outlet />
</div> </main>
{/* Mobile Floating Action Button */}
<Button <Button
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
size="icon" size="icon"
className="lg:hidden fixed right-4 bottom-4 w-14 h-14 rounded-full shadow-lg z-50" className="lg:hidden fixed right-5 bottom-5 w-14 h-14 rounded-2xl shadow-xl bg-gradient-to-r from-primary to-violet-500 hover:scale-105 transition-transform shadow-primary/20"
> >
<Plus className="w-6 h-6" /> <Plus className="w-6 h-6" />
<span className="sr-only">Add Service</span>
</Button> </Button>
</div> </div>
{/* Command Palette */}
<CommandPalette <CommandPalette
open={isCommandPaletteOpen} open={isCommandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)} onClose={() => setCommandPaletteOpen(false)}
+5
View File
@@ -0,0 +1,5 @@
interface AnalyticsOverviewProps {
timeRange: string;
}
export declare function AnalyticsOverview({ timeRange }: AnalyticsOverviewProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,77 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useQuery } from '@tanstack/react-query';
import { analyticsApi } from '@/lib/api';
import { TrendingUp, Users, Eye, MousePointer, Clock, Activity, ArrowUp, ArrowDown } from 'lucide-react';
export function AnalyticsOverview({ timeRange }) {
const { data: overviewData, isLoading, error } = useQuery({
queryKey: ['analytics-overview', timeRange],
queryFn: () => analyticsApi.getOverview(timeRange),
refetchInterval: 30000, // Refresh every 30 seconds
});
if (isLoading) {
return (_jsx("div", { className: "grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs(Card, { className: "animate-pulse", children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0 pb-2", children: [_jsx("div", { className: "h-4 bg-gray-200 rounded w-20" }), _jsx("div", { className: "h-4 w-4 bg-gray-200 rounded" })] }), _jsxs(CardContent, { children: [_jsx("div", { className: "h-8 bg-gray-200 rounded w-16 mb-2" }), _jsx("div", { className: "h-4 bg-gray-200 rounded w-24" })] })] }, i))) }));
}
if (error) {
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "text-center text-red-600", children: "Failed to load analytics data. Please try again later." }) }) }));
}
const metrics = [
{
title: 'Unique Visitors',
value: overviewData?.visitors.current.toLocaleString() || '0',
change: overviewData?.visitors.change || 0,
trend: overviewData?.visitors.trend || 'up',
icon: Users,
format: 'number'
},
{
title: 'Page Views',
value: overviewData?.pageviews.current.toLocaleString() || '0',
change: overviewData?.pageviews.change || 0,
trend: overviewData?.pageviews.trend || 'up',
icon: Eye,
format: 'number'
},
{
title: 'Sessions',
value: overviewData?.sessions.current.toLocaleString() || '0',
change: overviewData?.sessions.change || 0,
trend: overviewData?.sessions.trend || 'up',
icon: MousePointer,
format: 'number'
},
{
title: 'Bounce Rate',
value: `${overviewData?.bounceRate.current || 0}%`,
change: overviewData?.bounceRate.change || 0,
trend: overviewData?.bounceRate.trend || 'up',
icon: Activity,
format: 'percentage'
},
{
title: 'Session Duration',
value: overviewData ?
`${Math.floor(overviewData.sessionDuration.current / 60)}m ${overviewData.sessionDuration.current % 60}s` :
'0m 0s',
change: overviewData?.sessionDuration.change || 0,
trend: overviewData?.sessionDuration.trend || 'up',
icon: Clock,
format: 'duration'
},
{
title: 'Conversion Rate',
value: `${overviewData?.conversionRate.current || 0}%`,
change: overviewData?.conversionRate.change || 0,
trend: overviewData?.conversionRate.trend || 'up',
icon: TrendingUp,
format: 'percentage'
}
];
const getTrendIcon = (trend) => {
return trend === 'up' ? (_jsx(ArrowUp, { className: "w-4 h-4 text-green-500" })) : (_jsx(ArrowDown, { className: "w-4 h-4 text-red-500" }));
};
const getTrendColor = (trend) => {
return trend === 'up' ? 'text-green-600' : 'text-red-600';
};
return (_jsx("div", { className: "grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6", children: metrics.map((metric) => (_jsxs(Card, { className: "relative overflow-hidden", children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0 pb-2", children: [_jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: metric.title }), _jsx(metric.icon, { className: "h-4 w-4 text-muted-foreground" })] }), _jsxs(CardContent, { children: [_jsx("div", { className: "text-2xl font-bold", children: metric.value }), _jsxs("div", { className: "flex items-center space-x-1 text-xs", children: [getTrendIcon(metric.trend), _jsxs("span", { className: getTrendColor(metric.trend), children: [Math.abs(metric.change), "%"] }), _jsx("span", { className: "text-muted-foreground", children: "from last period" })] })] })] }, metric.title))) }));
}
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AnalyticsOverview } from './AnalyticsOverview';
vi.mock('@/lib/api', () => ({
analyticsApi: {
getOverview: vi.fn(),
},
}));
import { analyticsApi } from '@/lib/api';
const mockAnalyticsApi = vi.mocked(analyticsApi);
const mockOverviewData = {
visitors: { current: 12500, previous: 11000, change: 12, trend: 'up' as const },
pageviews: { current: 45000, previous: 42000, change: 8, trend: 'up' as const },
sessions: { current: 8900, previous: 8500, change: 5, trend: 'up' as const },
bounceRate: { current: 35, previous: 38, change: -3, trend: 'down' as const },
sessionDuration: { current: 180, previous: 165, change: 10, trend: 'up' as const },
conversionRate: { current: 4.5, previous: 4.0, change: 0.5, trend: 'up' as const },
};
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('AnalyticsOverview', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('loading state', () => {
it('shows loading skeleton cards', () => {
mockAnalyticsApi.getOverview.mockImplementation(() => new Promise(() => {}));
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
const skeletonCards = document.querySelectorAll('.animate-pulse');
expect(skeletonCards.length).toBe(6);
});
});
describe('error state', () => {
it('shows error message when query fails', async () => {
mockAnalyticsApi.getOverview.mockRejectedValue(new Error('Failed to fetch'));
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Failed to load analytics data. Please try again later.')).toBeInTheDocument();
});
});
});
describe('success state', () => {
beforeEach(() => {
mockAnalyticsApi.getOverview.mockResolvedValue(mockOverviewData);
});
it('renders all metric cards', async () => {
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Unique Visitors')).toBeInTheDocument();
expect(screen.getByText('Page Views')).toBeInTheDocument();
expect(screen.getByText('Sessions')).toBeInTheDocument();
expect(screen.getByText('Bounce Rate')).toBeInTheDocument();
expect(screen.getByText('Session Duration')).toBeInTheDocument();
expect(screen.getByText('Conversion Rate')).toBeInTheDocument();
});
});
it('displays formatted visitor count', async () => {
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('12,500')).toBeInTheDocument();
});
});
it('displays formatted page views', async () => {
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('45,000')).toBeInTheDocument();
});
});
it('displays formatted bounce rate', async () => {
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('35%')).toBeInTheDocument();
});
});
it('displays formatted session duration', async () => {
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('3m 0s')).toBeInTheDocument();
});
});
it('displays formatted conversion rate', async () => {
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('4.5%')).toBeInTheDocument();
});
});
it('displays change percentage', async () => {
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('12%')).toBeInTheDocument();
});
});
it('displays "from last period" text', async () => {
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
const lastPeriodTexts = screen.getAllByText('from last period');
expect(lastPeriodTexts.length).toBe(6);
});
});
});
describe('API calls', () => {
it('calls getOverview with correct timeRange', async () => {
mockAnalyticsApi.getOverview.mockResolvedValue(mockOverviewData);
render(<AnalyticsOverview timeRange="30d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(mockAnalyticsApi.getOverview).toHaveBeenCalledWith('30d');
});
});
});
describe('trend indicators', () => {
beforeEach(() => {
mockAnalyticsApi.getOverview.mockResolvedValue(mockOverviewData);
});
it('shows up arrow for upward trend', async () => {
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Unique Visitors')).toBeInTheDocument();
});
const upArrows = document.querySelectorAll('.text-green-500');
expect(upArrows.length).toBeGreaterThan(0);
});
});
});
+5
View File
@@ -0,0 +1,5 @@
interface ContentAnalyticsProps {
timeRange: string;
}
export declare function ContentAnalytics({ timeRange: _timeRange }: ContentAnalyticsProps): import("react/jsx-runtime").JSX.Element;
export {};
File diff suppressed because one or more lines are too long
@@ -5,8 +5,6 @@ import {
FileText, FileText,
Eye, Eye,
MousePointer, MousePointer,
Clock,
TrendingUp,
ArrowUp, ArrowUp,
ArrowDown, ArrowDown,
BookOpen, BookOpen,
@@ -17,7 +15,7 @@ interface ContentAnalyticsProps {
timeRange: string; timeRange: string;
} }
export function ContentAnalytics({ timeRange }: ContentAnalyticsProps) { export function ContentAnalytics({ timeRange: _timeRange }: ContentAnalyticsProps) {
// Mock data - in real implementation, this would come from Umami API // Mock data - in real implementation, this would come from Umami API
const contentData = { const contentData = {
topPages: [ topPages: [
+6
View File
@@ -0,0 +1,6 @@
interface CustomMetricsDashboardProps {
projectId?: string;
timeRange: string;
}
export declare function CustomMetricsDashboard({ projectId, timeRange }: CustomMetricsDashboardProps): import("react/jsx-runtime").JSX.Element;
export {};
File diff suppressed because one or more lines are too long
@@ -7,7 +7,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Cpu, Cpu,
HardDrive, HardDrive,
Wifi,
Clock, Clock,
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
@@ -15,14 +14,12 @@ import {
CheckCircle, CheckCircle,
Activity, Activity,
Zap, Zap,
Server,
MemoryStick, MemoryStick,
Network, Network,
Timer, Timer,
Users Users
} from 'lucide-react'; } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { analyticsApi } from '@/lib/api';
interface CustomMetricsDashboardProps { interface CustomMetricsDashboardProps {
projectId?: string; projectId?: string;
+1
View File
@@ -0,0 +1 @@
export declare function RealTimeAnalytics(): import("react/jsx-runtime").JSX.Element;
File diff suppressed because one or more lines are too long
@@ -7,7 +7,6 @@ import {
Users, Users,
Eye, Eye,
MousePointer, MousePointer,
Globe,
Monitor, Monitor,
Smartphone, Smartphone,
Clock, Clock,
@@ -16,12 +15,12 @@ import {
} from 'lucide-react'; } from 'lucide-react';
export function RealTimeAnalytics() { export function RealTimeAnalytics() {
const [currentTime, setCurrentTime] = useState(new Date()); const [_currentTime, setCurrentTime] = useState(new Date());
const [activeUsers, setActiveUsers] = useState(127); const [activeUsers, setActiveUsers] = useState(127);
const [currentVisitors, setCurrentVisitors] = useState(34); const [currentVisitors, setCurrentVisitors] = useState(34);
// Mock real-time data - in real implementation, this would update from WebSocket/API // Mock real-time data - in real implementation, this would update from WebSocket/API
const [realTimeData, setRealTimeData] = useState({ const [realTimeData, _setRealTimeData] = useState({
onlineUsers: 127, onlineUsers: 127,
currentVisitors: 34, currentVisitors: 34,
pageviews: [ pageviews: [
+5
View File
@@ -0,0 +1,5 @@
interface TrafficAnalyticsProps {
timeRange: string;
}
export declare function TrafficAnalytics({ timeRange: _timeRange }: TrafficAnalyticsProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,110 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Search, Globe, ExternalLink, MousePointer, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
export function TrafficAnalytics({ timeRange: _timeRange }) {
// Mock data - in real implementation, this would come from Umami API
const trafficData = {
sources: [
{
name: 'Organic Search',
percentage: 35,
visitors: 15832,
trend: 'up',
change: 12.5
},
{
name: 'Direct Traffic',
percentage: 28,
visitors: 12666,
trend: 'up',
change: 8.3
},
{
name: 'Social Media',
percentage: 18,
visitors: 8142,
trend: 'down',
change: -3.2
},
{
name: 'Referral',
percentage: 12,
visitors: 5428,
trend: 'up',
change: 15.7
},
{
name: 'Email Marketing',
percentage: 4,
visitors: 1809,
trend: 'up',
change: 22.1
},
{
name: 'Paid Search',
percentage: 3,
visitors: 1357,
trend: 'down',
change: -8.9
}
],
referrers: [
{ name: 'google.com', visitors: 12456, percentage: 27.5 },
{ name: 'github.com', visitors: 8234, percentage: 18.2 },
{ name: 'stackoverflow.com', visitors: 5423, percentage: 12.0 },
{ name: 'twitter.com', visitors: 3612, percentage: 8.0 },
{ name: 'linkedin.com', visitors: 2891, percentage: 6.4 },
{ name: 'Others', visitors: 12618, percentage: 27.9 }
],
campaigns: [
{
name: 'Summer Launch 2024',
visitors: 8234,
conversionRate: 4.2,
revenue: 12456
},
{
name: 'Product Update',
visitors: 5423,
conversionRate: 3.8,
revenue: 8234
},
{
name: 'Newsletter Signup',
visitors: 3612,
conversionRate: 2.1,
revenue: 2891
},
{
name: 'Social Media Push',
visitors: 2891,
conversionRate: 1.8,
revenue: 1567
}
],
keywords: [
{ name: 'container orchestration', visitors: 3421, percentage: 12.3 },
{ name: 'paas platform', visitors: 2891, percentage: 10.4 },
{ name: 'docker deployment', visitors: 2456, percentage: 8.8 },
{ name: 'self-hosted analytics', visitors: 1987, percentage: 7.1 },
{ name: 'railway alternative', visitors: 1654, percentage: 5.9 }
]
};
const getTrendIcon = (trend) => {
return trend === 'up' ? (_jsx(ArrowUp, { className: "w-3 h-3 text-green-500" })) : (_jsx(ArrowDown, { className: "w-3 h-3 text-red-500" }));
};
const getSourceIcon = (source) => {
if (source.includes('Search'))
return _jsx(Search, { className: "w-4 h-4" });
if (source.includes('Direct'))
return _jsx(MousePointer, { className: "w-4 h-4" });
if (source.includes('Social'))
return _jsx(Globe, { className: "w-4 h-4" });
if (source.includes('Referral'))
return _jsx(ExternalLink, { className: "w-4 h-4" });
return _jsx(TrendingUp, { className: "w-4 h-4" });
};
return (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(TrendingUp, { className: "w-5 h-5" }), "Traffic Sources"] }) }), _jsx(CardContent, { className: "space-y-4", children: trafficData.sources.map((source) => (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [getSourceIcon(source.name), _jsx("span", { className: "text-sm font-medium", children: source.name }), _jsxs("div", { className: "flex items-center gap-1", children: [getTrendIcon(source.trend), _jsxs("span", { className: `text-xs ${source.trend === 'up' ? 'text-green-600' : 'text-red-600'}`, children: [Math.abs(source.change), "%"] })] })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [source.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [source.visitors.toLocaleString(), " visitors"] })] })] }), _jsx(Progress, { value: source.percentage, className: "h-2" })] }, source.name))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(ExternalLink, { className: "w-5 h-5" }), "Top Referrers"] }) }), _jsx(CardContent, { className: "space-y-3", children: trafficData.referrers.map((referrer) => (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "w-3 h-3 rounded-full bg-blue-500" }), _jsx("span", { className: "text-sm", children: referrer.name })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [referrer.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [referrer.visitors.toLocaleString(), " visitors"] })] })] }, referrer.name))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(TrendingUp, { className: "w-5 h-5" }), "Campaign Performance"] }) }), _jsx(CardContent, { className: "space-y-4", children: trafficData.campaigns.map((campaign) => (_jsxs("div", { className: "border rounded-lg p-3", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("h4", { className: "font-medium text-sm", children: campaign.name }), _jsxs(Badge, { variant: "secondary", children: [campaign.conversionRate, "% conversion"] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 text-xs", children: [_jsxs("div", { children: [_jsx("div", { className: "text-muted-foreground", children: "Visitors" }), _jsx("div", { className: "font-semibold", children: campaign.visitors.toLocaleString() })] }), _jsxs("div", { children: [_jsx("div", { className: "text-muted-foreground", children: "Revenue" }), _jsxs("div", { className: "font-semibold", children: ["$", campaign.revenue.toLocaleString()] })] })] })] }, campaign.name))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Search, { className: "w-5 h-5" }), "Top Search Keywords"] }) }), _jsx(CardContent, { className: "space-y-3", children: trafficData.keywords.map((keyword) => (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "w-3 h-3 rounded-full bg-green-500" }), _jsx("span", { className: "text-sm", children: keyword.name })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [keyword.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [keyword.visitors.toLocaleString(), " visitors"] })] })] }, keyword.name))) })] })] }));
}
@@ -15,7 +15,7 @@ interface TrafficAnalyticsProps {
timeRange: string; timeRange: string;
} }
export function TrafficAnalytics({ timeRange }: TrafficAnalyticsProps) { export function TrafficAnalytics({ timeRange: _timeRange }: TrafficAnalyticsProps) {
// Mock data - in real implementation, this would come from Umami API // Mock data - in real implementation, this would come from Umami API
const trafficData = { const trafficData = {
sources: [ sources: [
+5
View File
@@ -0,0 +1,5 @@
interface VisitorAnalyticsProps {
timeRange: string;
}
export declare function VisitorAnalytics({ timeRange: _timeRange }: VisitorAnalyticsProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,61 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Users, Globe, Monitor, Smartphone, Tablet, MapPin } from 'lucide-react';
export function VisitorAnalytics({ timeRange: _timeRange }) {
// Mock data - in real implementation, this would come from Umami API
const visitorData = {
newVsReturning: {
new: 68,
returning: 32
},
devices: {
desktop: 45,
mobile: 42,
tablet: 13
},
browsers: [
{ name: 'Chrome', percentage: 45, users: 20356 },
{ name: 'Safari', percentage: 28, users: 12666 },
{ name: 'Firefox', percentage: 12, users: 5428 },
{ name: 'Edge', percentage: 8, users: 3619 },
{ name: 'Others', percentage: 7, users: 3166 }
],
operatingSystems: [
{ name: 'Windows', percentage: 38, users: 17189 },
{ name: 'macOS', percentage: 32, users: 14475 },
{ name: 'Android', percentage: 18, users: 8142 },
{ name: 'iOS', percentage: 10, users: 4523 },
{ name: 'Linux', percentage: 2, users: 905 }
],
countries: [
{ name: 'United States', percentage: 35, users: 15832 },
{ name: 'United Kingdom', percentage: 18, users: 8142 },
{ name: 'Germany', percentage: 12, users: 5428 },
{ name: 'Canada', percentage: 8, users: 3619 },
{ name: 'France', percentage: 7, users: 3166 },
{ name: 'Others', percentage: 20, users: 9047 }
],
languages: [
{ name: 'English', percentage: 45, users: 20356 },
{ name: 'German', percentage: 15, users: 6785 },
{ name: 'French', percentage: 12, users: 5428 },
{ name: 'Spanish', percentage: 10, users: 4523 },
{ name: 'Others', percentage: 18, users: 8142 }
]
};
const getDeviceIcon = (device) => {
switch (device) {
case 'desktop':
return _jsx(Monitor, { className: "w-4 h-4" });
case 'mobile':
return _jsx(Smartphone, { className: "w-4 h-4" });
case 'tablet':
return _jsx(Tablet, { className: "w-4 h-4" });
default:
return _jsx(Monitor, { className: "w-4 h-4" });
}
};
return (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Users, { className: "w-5 h-5" }), "New vs Returning Visitors"] }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "secondary", children: "New" }), _jsx("span", { className: "text-sm", children: "First-time visitors" })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [visitorData.newVsReturning.new, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [(45234 * visitorData.newVsReturning.new / 100).toLocaleString(), " visitors"] })] })] }), _jsx(Progress, { value: visitorData.newVsReturning.new, className: "h-2" })] }), _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", children: "Returning" }), _jsx("span", { className: "text-sm", children: "Repeat visitors" })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [visitorData.newVsReturning.returning, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [(45234 * visitorData.newVsReturning.returning / 100).toLocaleString(), " visitors"] })] })] }), _jsx(Progress, { value: visitorData.newVsReturning.returning, className: "h-2" })] })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Monitor, { className: "w-5 h-5" }), "Device Breakdown"] }) }), _jsx(CardContent, { className: "space-y-4", children: Object.entries(visitorData.devices).map(([device, percentage]) => (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [getDeviceIcon(device), _jsx("span", { className: "text-sm capitalize", children: device })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [Math.floor(45234 * percentage / 100).toLocaleString(), " visitors"] })] })] }), _jsx(Progress, { value: percentage, className: "h-2" })] }, device))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Globe, { className: "w-5 h-5" }), "Top Browsers"] }) }), _jsx(CardContent, { className: "space-y-3", children: visitorData.browsers.map((browser) => (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "w-3 h-3 rounded-full bg-blue-500" }), _jsx("span", { className: "text-sm", children: browser.name })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [browser.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [browser.users.toLocaleString(), " users"] })] })] }, browser.name))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(MapPin, { className: "w-5 h-5" }), "Top Countries"] }) }), _jsx(CardContent, { className: "space-y-3", children: visitorData.countries.map((country) => (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "w-3 h-3 rounded-full bg-green-500" }), _jsx("span", { className: "text-sm", children: country.name })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [country.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [country.users.toLocaleString(), " visitors"] })] })] }, country.name))) })] })] }));
}
@@ -7,8 +7,6 @@ import {
Monitor, Monitor,
Smartphone, Smartphone,
Tablet, Tablet,
Clock,
TrendingUp,
MapPin MapPin
} from 'lucide-react'; } from 'lucide-react';
@@ -16,7 +14,7 @@ interface VisitorAnalyticsProps {
timeRange: string; timeRange: string;
} }
export function VisitorAnalytics({ timeRange }: VisitorAnalyticsProps) { export function VisitorAnalytics({ timeRange: _timeRange }: VisitorAnalyticsProps) {
// Mock data - in real implementation, this would come from Umami API // Mock data - in real implementation, this would come from Umami API
const visitorData = { const visitorData = {
newVsReturning: { newVsReturning: {
@@ -1,320 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
TrendingUp,
TrendingDown,
Users,
DollarSign,
MoreHorizontal,
Target,
Calendar,
Eye,
MousePointer,
ShoppingCart,
Activity,
Clock
} from 'lucide-react';
import { useState } from 'react';
interface Campaign {
id: string;
name: string;
status: 'active' | 'completed' | 'paused' | 'draft';
reach: number;
engagement: number;
conversions: number;
revenue: number;
trend: string;
startDate: string;
endDate?: string;
budget: number;
spent: number;
ctr: number; // Click-through rate
cpc: number; // Cost per click
platform: 'google' | 'facebook' | 'instagram' | 'email' | 'linkedin';
}
const campaignData: Campaign[] = [
{
id: 'CAMP-001',
name: 'Summer Launch 2024',
status: 'active',
reach: 45234,
engagement: 68,
conversions: 892,
revenue: 12450,
trend: '+15%',
startDate: '2024-06-01',
endDate: '2024-08-31',
budget: 15000,
spent: 8750,
ctr: 2.8,
cpc: 0.45,
platform: 'google'
},
{
id: 'CAMP-002',
name: 'Product Demo Series',
status: 'completed',
reach: 28901,
engagement: 72,
conversions: 456,
revenue: 8900,
trend: '+8%',
startDate: '2024-05-15',
endDate: '2024-06-15',
budget: 8000,
spent: 7200,
ctr: 3.2,
cpc: 0.38,
platform: 'facebook'
},
{
id: 'CAMP-003',
name: 'Newsletter Campaign',
status: 'active',
reach: 18923,
engagement: 54,
conversions: 234,
revenue: 3450,
trend: '-2%',
startDate: '2024-07-01',
budget: 5000,
spent: 2100,
ctr: 1.8,
cpc: 0.12,
platform: 'email'
}
];
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge className="bg-green-100 text-green-800 border-green-200">Active</Badge>;
case 'completed':
return <Badge variant="secondary">Completed</Badge>;
case 'paused':
return <Badge variant="outline">Paused</Badge>;
case 'draft':
return <Badge variant="outline">Draft</Badge>;
default:
return <Badge variant="outline">Unknown</Badge>;
}
};
const getPlatformIcon = (platform: string) => {
switch (platform) {
case 'google':
return <Target className="w-4 h-4 text-blue-600" />;
case 'facebook':
return <Users className="w-4 h-4 text-blue-500" />;
case 'instagram':
return <Eye className="w-4 h-4 text-pink-600" />;
case 'email':
return <Activity className="w-4 h-4 text-orange-600" />;
case 'linkedin':
return <Users className="w-4 h-4 text-blue-700" />;
default:
return <Target className="w-4 h-4" />;
}
};
const getTrendIcon = (trend: string) => {
if (trend.startsWith('+')) {
return <TrendingUp className="w-3 h-3 text-green-600" />;
} else if (trend.startsWith('-')) {
return <TrendingDown className="w-3 h-3 text-red-600" />;
}
return <Activity className="w-3 h-3 text-gray-600" />;
};
const getTrendColor = (trend: string) => {
if (trend.startsWith('+')) {
return 'text-green-600 bg-green-50';
} else if (trend.startsWith('-')) {
return 'text-red-600 bg-red-50';
}
return 'text-gray-600 bg-gray-50';
};
export function CampaignDataCard() {
const [selectedCampaign, setSelectedCampaign] = useState<string | null>(null);
const totalRevenue = campaignData.reduce((sum, item) => sum + item.revenue, 0);
const totalConversions = campaignData.reduce((sum, item) => sum + item.conversions, 0);
const totalBudget = campaignData.reduce((sum, item) => sum + item.budget, 0);
const totalSpent = campaignData.reduce((sum, item) => sum + item.spent, 0);
const avgEngagement = Math.round(campaignData.reduce((sum, item) => sum + item.engagement, 0) / campaignData.length);
const activeCampaigns = campaignData.filter(c => c.status === 'active').length;
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">Campaign Data</CardTitle>
<div className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
</div>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Key Metrics Overview */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Total Revenue</span>
</div>
<div className="text-lg font-bold">${totalRevenue.toLocaleString()}</div>
<div className="flex items-center gap-1 text-xs">
<TrendingUp className="w-3 h-3 text-green-600" />
<span className="text-green-600">+18% vs last month</span>
</div>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-2 mb-1">
<ShoppingCart className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Conversions</span>
</div>
<div className="text-lg font-bold">{totalConversions.toLocaleString()}</div>
<div className="flex items-center gap-1 text-xs">
<TrendingUp className="w-3 h-3 text-green-600" />
<span className="text-green-600">+12% vs last month</span>
</div>
</div>
</div>
{/* Budget Utilization */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Budget Utilization</span>
<span className="font-medium">${totalSpent.toLocaleString()} / ${totalBudget.toLocaleString()}</span>
</div>
<Progress value={(totalSpent / totalBudget) * 100} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{Math.round((totalSpent / totalBudget) * 100)}% spent</span>
<span>${(totalBudget - totalSpent).toLocaleString()} remaining</span>
</div>
</div>
{/* Performance Metrics */}
<div className="grid grid-cols-3 gap-3 text-center">
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-sm font-bold">{avgEngagement}%</div>
<div className="text-xs text-muted-foreground">Avg Engagement</div>
</div>
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-sm font-bold">{activeCampaigns}</div>
<div className="text-xs text-muted-foreground">Active</div>
</div>
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-sm font-bold">2.6%</div>
<div className="text-xs text-muted-foreground">Avg CTR</div>
</div>
</div>
{/* Campaign List */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Active Campaigns</div>
<div className="space-y-2">
{campaignData.filter(c => c.status === 'active').slice(0, 3).map((campaign) => (
<div
key={campaign.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
selectedCampaign === campaign.id
? 'bg-primary/10 border-primary/30'
: 'bg-muted/30 border-border hover:bg-muted/50'
}`}
onClick={() => setSelectedCampaign(campaign.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
{getPlatformIcon(campaign.platform)}
<span className="text-sm font-medium truncate">{campaign.name}</span>
{getStatusBadge(campaign.status)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
<div>
<div className="text-muted-foreground">Reach</div>
<div className="font-medium">{campaign.reach.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">Revenue</div>
<div className="font-medium">${campaign.revenue.toLocaleString()}</div>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<Eye className="w-3 h-3 text-muted-foreground" />
<span>{campaign.ctr}% CTR</span>
</div>
<div className="flex items-center gap-1">
<MousePointer className="w-3 h-3 text-muted-foreground" />
<span>${campaign.cpc} CPC</span>
</div>
</div>
<Badge variant="outline" className={`text-xs ${getTrendColor(campaign.trend)}`}>
{getTrendIcon(campaign.trend)}
{campaign.trend}
</Badge>
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Campaign Timeline */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Recent Activity</div>
<div className="space-y-2">
{campaignData.slice(0, 2).map((campaign) => (
<div key={campaign.id} className="flex items-center gap-3 text-xs">
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center">
<Calendar className="w-3 h-3" />
</div>
<div className="flex-1">
<div className="font-medium">{campaign.name}</div>
<div className="text-muted-foreground">
{campaign.status === 'active' ? 'Started' : 'Completed'} {campaign.startDate}
</div>
</div>
<div className="text-muted-foreground">
{campaign.status === 'active' ? (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>Active</span>
</div>
) : (
<span>Ended {campaign.endDate}</span>
)}
</div>
</div>
))}
</div>
</div>
{/* Quick Actions */}
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1 text-xs">
View All Campaigns
</Button>
<Button size="sm" className="flex-1 text-xs">
Create Campaign
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -1,165 +0,0 @@
import { MetricCard } from './MetricCard';
import { TrendingUp, TrendingDown, BarChart3 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { useState } from 'react';
export function ConversionRateCard() {
const [selectedPeriod, setSelectedPeriod] = useState('1W');
const conversionData = {
'1D': {
rate: '15.2%',
trend: '+0.8%',
direction: 'up' as 'up',
funnel: {
cart: { count: 384, trend: '+3%' },
checkout: { count: 215, trend: '+2%' },
payment: { count: 184, trend: '+1%' }
}
},
'1W': {
rate: '16.9%',
trend: '+2.1%',
direction: 'up' as 'up',
funnel: {
cart: { count: 3842, trend: '+12%' },
checkout: { count: 2156, trend: '+8%' },
payment: { count: 1842, trend: '+5%' }
}
},
'1M': {
rate: '18.3%',
trend: '+3.4%',
direction: 'up' as 'up',
funnel: {
cart: { count: 16547, trend: '+18%' },
checkout: { count: 9234, trend: '+14%' },
payment: { count: 7892, trend: '+11%' }
}
},
'3M': {
rate: '19.7%',
trend: '+4.8%',
direction: 'up' as 'up',
funnel: {
cart: { count: 52341, trend: '+28%' },
checkout: { count: 29456, trend: '+22%' },
payment: { count: 25123, trend: '+19%' }
}
},
'6M': {
rate: '21.2%',
trend: '+6.3%',
direction: 'up' as 'up',
funnel: {
cart: { count: 108934, trend: '+41%' },
checkout: { count: 61234, trend: '+35%' },
payment: { count: 52345, trend: '+31%' }
}
},
'1Y': {
rate: '23.8%',
trend: '+8.9%',
direction: 'up' as 'up',
funnel: {
cart: { count: 224567, trend: '+67%' },
checkout: { count: 126789, trend: '+58%' },
payment: { count: 108234, trend: '+52%' }
}
},
};
const currentData = conversionData[selectedPeriod as keyof typeof conversionData];
return (
<MetricCard
title="Conversion Rate"
value={currentData.rate}
trend={{
value: currentData.trend,
direction: currentData.direction
}}
actionLabel="Details"
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
>
<div className="w-full flex-col gap-3">
{/* Conversion Funnel */}
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<div className="flex-1 text-sm text-muted-foreground font-medium">Added to Cart</div>
<div className="flex items-center gap-1.5">
<div className="min-w-16 text-sm tabular-nums text-muted-foreground">{currentData.funnel.cart.count.toLocaleString()}</div>
<Badge
variant={currentData.funnel.cart.trend.startsWith('+') ? 'default' : 'destructive'}
className="h-5 gap-1 px-2 text-xs"
>
{currentData.funnel.cart.trend.startsWith('+') ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.funnel.cart.trend}
</Badge>
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1 text-sm text-muted-foreground font-medium">Checkout Started</div>
<div className="flex items-center gap-1.5">
<div className="min-w-16 text-sm tabular-nums text-muted-foreground">{currentData.funnel.checkout.count.toLocaleString()}</div>
<Badge
variant={currentData.funnel.checkout.trend.startsWith('+') ? 'default' : 'destructive'}
className="h-5 gap-1 px-2 text-xs"
>
{currentData.funnel.checkout.trend.startsWith('+') ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.funnel.checkout.trend}
</Badge>
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1 text-sm text-muted-foreground font-medium">Payment Completed</div>
<div className="flex items-center gap-1.5">
<div className="min-w-16 text-sm tabular-nums text-muted-foreground">{currentData.funnel.payment.count.toLocaleString()}</div>
<Badge
variant={currentData.funnel.payment.trend.startsWith('+') ? 'default' : 'destructive'}
className="h-5 gap-1 px-2 text-xs"
>
{currentData.funnel.payment.trend.startsWith('+') ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.funnel.payment.trend}
</Badge>
</div>
</div>
</div>
{/* Mini Sparkline Visualization */}
<div className="mt-4 pt-3 border-t border-border/20">
<div className="flex items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<BarChart3 className="w-4 h-4" />
<span className="text-xs">Conversion trend</span>
</div>
</div>
<div className="mt-2 h-8 w-full bg-muted/20 rounded-sm flex items-end justify-between gap-1 px-1">
{[65, 72, 68, 75, 82, 79, 85, 88, 92, 87, 91, 95].map((height, i) => (
<div
key={i}
className="flex-1 bg-primary/60 rounded-sm transition-all duration-300 hover:bg-primary/80"
style={{ height: `${height}%` }}
/>
))}
</div>
</div>
</div>
</MetricCard>
);
}
+1
View File
@@ -0,0 +1 @@
export {};
+18
View File
@@ -0,0 +1,18 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TrendingUp, TrendingDown, FileText, BarChart3 } from 'lucide-react';
import { useState } from 'react';
function MetricCard({ title, value, trend, timePeriods = ['1D', '1W', '1M', '3M', '6M', '1Y'], actionLabel = 'Report', children, selectedPeriod = '1W', onPeriodChange }) {
const [currentPeriod, setCurrentPeriod] = useState(selectedPeriod);
const handlePeriodChange = (period) => {
setCurrentPeriod(period);
onPeriodChange?.(period);
};
return (_jsx(Card, { className: "w-full shadow-sm border-0 ring-1 ring-inset ring-border/20", children: _jsxs(CardContent, { className: "p-5 space-y-5", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsxs("div", { className: "flex-1", children: [_jsx("div", { className: "text-sm text-muted-foreground font-medium", children: title }), _jsxs("div", { className: "mt-1 flex items-center gap-2", children: [_jsx("div", { className: "text-2xl font-bold text-foreground", children: value }), trend && (_jsxs(Badge, { variant: trend.direction === 'up' ? 'default' : 'destructive', className: `h-5 gap-1.5 px-2 text-xs font-medium ${trend.direction === 'up'
? 'bg-green-100 text-green-800 border-green-200'
: 'bg-red-100 text-red-800 border-red-200'}`, children: [trend.direction === 'up' ? (_jsx(TrendingUp, { className: "w-3 h-3" })) : (_jsx(TrendingDown, { className: "w-3 h-3" })), trend.value] }))] })] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-7 gap-2.5 px-2 text-xs hover:bg-muted/50 transition-colors", children: [actionLabel === 'Report' ? _jsx(FileText, { className: "w-3 h-3" }) : _jsx(BarChart3, { className: "w-3 h-3" }), actionLabel] })] }), children && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-full h-px bg-border/20" }), children] })), timePeriods && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-full h-px bg-border/20" }), _jsx("div", { className: "flex gap-0.5", role: "radiogroup", children: timePeriods.map((period) => (_jsx(Button, { variant: currentPeriod === period ? 'default' : 'ghost', size: "sm", onClick: () => handlePeriodChange(period), className: `h-6 px-3 text-xs first:rounded-l-md last:rounded-r-md transition-colors ${currentPeriod === period
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted/50 text-muted-foreground'}`, children: period }, period))) })] }))] }) }));
}
+1 -1
View File
@@ -18,7 +18,7 @@ interface MetricCardProps {
onPeriodChange?: (period: string) => void; onPeriodChange?: (period: string) => void;
} }
export function MetricCard({ function _MetricCard({
title, title,
value, value,
trend, trend,
@@ -1,290 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import {
TrendingUp,
DollarSign,
MoreHorizontal,
Box,
Star,
ArrowUpRight,
ArrowDownRight,
Minus,
Activity,
ShoppingCart
} from 'lucide-react';
import { useState } from 'react';
interface ProductCategory {
name: string;
value: number;
percentage: number;
color: string;
growth: string;
status: 'up' | 'down' | 'neutral';
products: number;
avgPrice: number;
topSeller?: string;
}
const categoryData: ProductCategory[] = [
{
name: 'Premium',
value: 6450,
percentage: 36,
color: '#f59e0b',
growth: '+12%',
status: 'up',
products: 145,
avgPrice: 44.48,
topSeller: 'Premium Suite'
},
{
name: 'Regular',
value: 5320,
percentage: 30,
color: '#6b7280',
growth: '+5%',
status: 'up',
products: 289,
avgPrice: 18.41,
topSeller: 'Standard Pack'
},
{
name: 'New',
value: 3280,
percentage: 18,
color: '#10b981',
growth: '+28%',
status: 'up',
products: 67,
avgPrice: 48.96,
topSeller: 'Starter Kit'
},
{
name: 'Others',
value: 2850,
percentage: 16,
color: '#e5e7eb',
growth: '-3%',
status: 'down',
products: 103,
avgPrice: 27.67,
topSeller: 'Misc Items'
}
];
const monthlySalesData = [
{ month: 'Jan', Premium: 5200, Regular: 4800, New: 2100, Others: 2900 },
{ month: 'Feb', Premium: 5400, Regular: 4900, New: 2400, Others: 2800 },
{ month: 'Mar', Premium: 5800, Regular: 5100, New: 2800, Others: 2700 },
{ month: 'Apr', Premium: 6200, Regular: 5300, New: 3200, Others: 2900 },
{ month: 'May', Premium: 6450, Regular: 5320, New: 3280, Others: 2850 }
];
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-white p-3 border rounded-lg shadow-sm">
<p className="font-medium text-sm">{payload[0].name}</p>
<p className="text-sm text-muted-foreground">
${payload[0].value.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">{payload[0].payload.percentage}%</p>
</div>
);
}
return null;
};
const getTrendIcon = (status: string) => {
switch (status) {
case 'up':
return <ArrowUpRight className="w-3 h-3 text-green-600" />;
case 'down':
return <ArrowDownRight className="w-3 h-3 text-red-600" />;
default:
return <Minus className="w-3 h-3 text-gray-600" />;
}
};
const getTrendColor = (status: string) => {
switch (status) {
case 'up':
return 'text-green-600 bg-green-50';
case 'down':
return 'text-red-600 bg-red-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
export function ProductCategoriesCard() {
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const totalRevenue = categoryData.reduce((sum, item) => sum + item.value, 0);
const totalProducts = categoryData.reduce((sum, item) => sum + item.products, 0);
const avgGrowth = '+10.5%';
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">Product Categories</CardTitle>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
</div>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Revenue Overview */}
<div className="text-center p-3 rounded-lg bg-muted/50">
<div className="flex items-center justify-center gap-2 mb-1">
<DollarSign className="w-5 h-5 text-green-600" />
<div className="text-2xl font-bold">${totalRevenue.toLocaleString()}</div>
</div>
<div className="text-xs text-muted-foreground">Total Revenue by Category</div>
<div className="flex items-center justify-center gap-1 mt-1">
<TrendingUp className="w-3 h-3 text-green-600" />
<span className="text-xs text-green-600">{avgGrowth} vs last month</span>
</div>
</div>
{/* Pie Chart */}
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categoryData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={70}
paddingAngle={2}
dataKey="value"
>
{categoryData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.color}
className="hover:opacity-80 transition-opacity cursor-pointer"
onClick={() => setSelectedCategory(entry.name)}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
{/* Category Breakdown */}
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground">Category Performance</div>
<div className="space-y-2">
{categoryData.map((category) => (
<div
key={category.name}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
selectedCategory === category.name
? 'bg-primary/10 border-primary/30'
: 'bg-muted/30 border-border hover:bg-muted/50'
}`}
onClick={() => setSelectedCategory(category.name)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: category.color }}
/>
<span className="text-sm font-medium">{category.name}</span>
<div className="flex items-center gap-1">
<Box className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{category.products}</span>
</div>
</div>
<Badge variant="outline" className={`text-xs ${getTrendColor(category.status)}`}>
{getTrendIcon(category.status)}
{category.growth}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
<div>
<div className="text-muted-foreground">Revenue</div>
<div className="font-medium">${category.value.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">Avg Price</div>
<div className="font-medium">${category.avgPrice}</div>
</div>
</div>
{category.topSeller && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Star className="w-3 h-3 text-yellow-500" />
<span>Top: {category.topSeller}</span>
</div>
)}
</div>
))}
</div>
</div>
{/* Monthly Sales Trend */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">5-Month Trend</div>
<div className="h-32">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlySalesData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="month" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip
contentStyle={{ fontSize: '12px', padding: '4px' }}
labelStyle={{ fontSize: '10px' }}
/>
<Bar dataKey="Premium" fill="#f59e0b" />
<Bar dataKey="Regular" fill="#6b7280" />
<Bar dataKey="New" fill="#10b981" />
<Bar dataKey="Others" fill="#e5e7eb" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-sm font-bold">{totalProducts}</div>
<div className="text-xs text-muted-foreground">Products</div>
</div>
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-sm font-bold">${Math.round(totalRevenue / totalProducts)}</div>
<div className="text-xs text-muted-foreground">Avg Price</div>
</div>
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-sm font-bold">4</div>
<div className="text-xs text-muted-foreground">Categories</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1 text-xs">
<ShoppingCart className="w-3 h-3 mr-1" />
Manage Products
</Button>
<Button size="sm" className="flex-1 text-xs">
<Activity className="w-3 h-3 mr-1" />
View Analytics
</Button>
</div>
</CardContent>
</Card>
);
}
+1
View File
@@ -0,0 +1 @@
export declare function ProjectCanvas(): import("react/jsx-runtime").JSX.Element;
File diff suppressed because one or more lines are too long
@@ -1,167 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
GitBranch,
Database,
Settings,
UserPlus,
AlertTriangle,
CheckCircle,
Clock
} from 'lucide-react';
export function RecentActivitiesCard() {
const activities = [
{
id: 1,
type: 'deployment',
title: 'web-app deployed successfully',
description: 'Version 2.1.0 deployed to production',
time: '2 minutes ago',
status: 'success',
icon: GitBranch
},
{
id: 2,
type: 'database',
title: 'Database backup completed',
description: 'PostgreSQL backup automated',
time: '15 minutes ago',
status: 'success',
icon: Database
},
{
id: 3,
type: 'settings',
title: 'Configuration updated',
description: 'Environment variables modified',
time: '1 hour ago',
status: 'warning',
icon: Settings
},
{
id: 4,
type: 'user',
title: 'New team member added',
description: 'John Doe joined the project',
time: '3 hours ago',
status: 'success',
icon: UserPlus
},
{
id: 5,
type: 'alert',
title: 'High memory usage detected',
description: 'Node-3 memory usage at 85%',
time: '4 hours ago',
status: 'error',
icon: AlertTriangle
},
{
id: 6,
type: 'deployment',
title: 'API server restarted',
description: 'Automatic restart after crash',
time: '6 hours ago',
status: 'success',
icon: GitBranch
},
{
id: 7,
type: 'deployment',
title: 'Worker service updated',
description: 'Background tasks service patched',
time: '8 hours ago',
status: 'success',
icon: GitBranch
}
];
const getStatusIcon = (status: string) => {
switch (status) {
case 'success':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'warning':
return <Clock className="w-4 h-4 text-yellow-500" />;
case 'error':
return <AlertTriangle className="w-4 h-4 text-red-500" />;
default:
return <Clock className="w-4 h-4 text-muted-foreground" />;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'success':
return <Badge variant="default" className="bg-green-100 text-green-800">Success</Badge>;
case 'warning':
return <Badge variant="secondary">Warning</Badge>;
case 'error':
return <Badge variant="destructive">Error</Badge>;
default:
return <Badge variant="outline">Info</Badge>;
}
};
return (
<Card className="w-full">
<CardHeader>
<div className="flex items-start gap-3">
<div className="flex-1">
<CardTitle>Recent Activities</CardTitle>
<CardDescription>7 new activities today</CardDescription>
</div>
<Button variant="outline" size="sm" className="h-7 gap-2.5 px-2">
Details
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Time Filter */}
<div className="flex flex-wrap gap-2.5" role="radiogroup">
{['Today', 'Yesterday', 'This Week', 'This Month'].map((period) => (
<Button
key={period}
variant={period === 'Today' ? 'default' : 'ghost'}
size="sm"
className="h-7 px-2.5 text-sm"
>
{period}
</Button>
))}
</div>
{/* Activities List */}
<div className="space-y-3">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="flex-shrink-0 mt-0.5">
<activity.icon className="w-4 h-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-foreground truncate">
{activity.title}
</h4>
{getStatusIcon(activity.status)}
</div>
<p className="text-xs text-muted-foreground mb-2">
{activity.description}
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{activity.time}
</span>
{getStatusBadge(activity.status)}
</div>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -1,30 +0,0 @@
import { MetricCard } from './MetricCard';
import { useState } from 'react';
export function SalesMetricCard() {
const [selectedPeriod, setSelectedPeriod] = useState('1W');
const salesData = {
'1D': { value: '$128.32', trend: '+2%', direction: 'up' as const },
'1W': { value: '$897.24', trend: '+5.2%', direction: 'up' as const },
'1M': { value: '$3,847.92', trend: '+12.8%', direction: 'up' as const },
'3M': { value: '$11,543.76', trend: '+18.3%', direction: 'up' as const },
'6M': { value: '$23,087.52', trend: '+24.1%', direction: 'up' as const },
'1Y': { value: '$46,175.04', trend: '+31.7%', direction: 'up' as const },
};
const currentData = salesData[selectedPeriod as keyof typeof salesData];
return (
<MetricCard
title="Total Sales"
value={currentData.value}
trend={{
value: currentData.trend,
direction: currentData.direction
}}
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
/>
);
}
@@ -1,60 +0,0 @@
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, Clock, Package, Truck } from 'lucide-react';
export function ShippingTrackingCard() {
const trackingSteps = [
{ icon: Package, label: 'Order Placed', time: 'Dec 10, 2:30 PM', completed: true },
{ icon: CheckCircle, label: 'Processing', time: 'Dec 10, 4:15 PM', completed: true },
{ icon: Truck, label: 'Shipped', time: 'Dec 11, 10:00 AM', completed: true },
{ icon: Clock, label: 'Out for Delivery', time: 'Dec 12, 8:00 AM', completed: false },
{ icon: CheckCircle, label: 'Delivered', time: 'Expected by 6:00 PM', completed: false }
];
return (
<Card className="w-full">
<CardContent className="p-5">
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-start gap-2">
<div className="flex-1">
<div className="flex items-center gap-1">
<div className="text-sm text-muted-foreground">Shipping Tracking</div>
</div>
<div className="mt-1 text-lg font-semibold">Order #12345</div>
</div>
<Badge variant="secondary">In Transit</Badge>
</div>
{/* Tracking Steps */}
<div className="space-y-4">
{trackingSteps.map((step, index) => (
<div key={step.label} className="flex items-center gap-3">
<div className="flex-shrink-0">
<step.icon
className={`w-5 h-5 ${
step.completed ? 'text-green-600' : 'text-muted-foreground'
}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium ${
step.completed ? 'text-foreground' : 'text-muted-foreground'
}`}>
{step.label}
</div>
<div className="text-xs text-muted-foreground">{step.time}</div>
</div>
{index < trackingSteps.length - 1 && (
<div className={`w-px h-8 ${
index < 2 ? 'bg-green-600' : 'bg-border'
}`} />
)}
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -1,275 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
TrendingUp,
TrendingDown,
Minus,
MoreHorizontal,
AlertTriangle,
CheckCircle,
Clock,
Users,
Package,
Activity,
Phone,
Mail,
MessageSquare,
Share2
} from 'lucide-react';
import { useState } from 'react';
interface Ticket {
id: string;
subject: string;
priority: 'high' | 'medium' | 'low';
status: 'open' | 'in_progress' | 'resolved';
assignee?: string;
createdAt: string;
category: 'bug' | 'feature' | 'support' | 'incident';
}
const mockTickets: Ticket[] = [
{
id: 'TK-001',
subject: 'Database connection timeout in production',
priority: 'high',
status: 'in_progress',
assignee: 'Sarah Chen',
createdAt: '2 hours ago',
category: 'incident'
},
{
id: 'TK-002',
subject: 'Add custom domain support',
priority: 'medium',
status: 'open',
assignee: 'Mike Johnson',
createdAt: '5 hours ago',
category: 'feature'
},
{
id: 'TK-003',
subject: 'Deployment logs not showing for Node.js apps',
priority: 'medium',
status: 'resolved',
assignee: 'Alex Kumar',
createdAt: '1 day ago',
category: 'bug'
}
];
const supportData = [
{ channel: 'Email', tickets: 245, trend: '+12%', status: 'up', icon: Mail },
{ channel: 'Chat', tickets: 189, trend: '+8%', status: 'up', icon: MessageSquare },
{ channel: 'Phone', tickets: 67, trend: '-3%', status: 'down', icon: Phone },
{ channel: 'Social', tickets: 34, trend: '0%', status: 'neutral', icon: Share2 },
];
const getTrendIcon = (status: string) => {
switch (status) {
case 'up':
return <TrendingUp className="w-4 h-4 text-green-600" />;
case 'down':
return <TrendingDown className="w-4 h-4 text-red-600" />;
default:
return <Minus className="w-4 h-4 text-gray-600" />;
}
};
const getTrendColor = (status: string) => {
switch (status) {
case 'up':
return 'text-green-600 bg-green-50';
case 'down':
return 'text-red-600 bg-red-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
return 'bg-red-100 text-red-800 border-red-200';
case 'medium':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'low':
return 'bg-green-100 text-green-800 border-green-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'open':
return <Clock className="w-3 h-3" />;
case 'in_progress':
return <Activity className="w-3 h-3" />;
case 'resolved':
return <CheckCircle className="w-3 h-3" />;
default:
return <Clock className="w-3 h-3" />;
}
};
const getCategoryIcon = (category: string) => {
switch (category) {
case 'incident':
return <AlertTriangle className="w-3 h-3 text-red-600" />;
case 'bug':
return <AlertTriangle className="w-3 h-3 text-orange-600" />;
case 'feature':
return <Package className="w-3 h-3 text-blue-600" />;
case 'support':
return <Users className="w-3 h-3 text-green-600" />;
default:
return <AlertTriangle className="w-3 h-3" />;
}
};
export function SupportAnalyticsCard() {
const [selectedTicket, setSelectedTicket] = useState<string | null>(null);
const openTickets = mockTickets.filter(t => t.status !== 'resolved').length;
const highPriorityTickets = mockTickets.filter(t => t.priority === 'high' && t.status !== 'resolved').length;
const avgResolutionTime = '4.2 hours';
const satisfactionRate = '94%';
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">Support Analytics</CardTitle>
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
</div>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Metrics Overview */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Open Tickets</span>
</div>
<div className="text-lg font-bold">{openTickets}</div>
<div className="flex items-center gap-1 text-xs">
<TrendingDown className="w-3 h-3 text-green-600" />
<span className="text-green-600">-12% from last week</span>
</div>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-2 mb-1">
<AlertTriangle className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">High Priority</span>
</div>
<div className="text-lg font-bold text-red-600">{highPriorityTickets}</div>
<div className="flex items-center gap-1 text-xs">
<TrendingUp className="w-3 h-3 text-red-600" />
<span className="text-red-600">+2 new today</span>
</div>
</div>
</div>
{/* Channel Breakdown */}
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground">Support Channels</div>
{supportData.map((channel) => {
const Icon = channel.icon;
return (
<div key={channel.channel} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center">
<Icon className="w-4 h-4" />
</div>
<div>
<div className="font-medium text-sm">{channel.channel}</div>
<div className="text-xs text-muted-foreground">{channel.tickets} tickets</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className={getTrendColor(channel.status)}>
{getTrendIcon(channel.status)}
{channel.trend}
</Badge>
</div>
</div>
);
})}
</div>
{/* Performance Metrics */}
<div className="space-y-3">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Avg Resolution Time</span>
<span className="font-medium">{avgResolutionTime}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Customer Satisfaction</span>
<span className="font-medium text-green-600">{satisfactionRate}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Response Rate</span>
<span className="font-medium">87%</span>
</div>
</div>
{/* Recent Tickets */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Recent Activity</div>
<div className="space-y-2">
{mockTickets.slice(0, 3).map((ticket) => (
<div
key={ticket.id}
className={`p-2 rounded-lg border cursor-pointer transition-colors ${
selectedTicket === ticket.id
? 'bg-primary/10 border-primary/30'
: 'bg-muted/30 border-border hover:bg-muted/50'
}`}
onClick={() => setSelectedTicket(ticket.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{getCategoryIcon(ticket.category)}
<span className="text-xs font-medium truncate">{ticket.id}</span>
<Badge className={`text-xs px-1.5 py-0.5 ${getPriorityColor(ticket.priority)}`}>
{ticket.priority}
</Badge>
</div>
<div className="text-xs text-muted-foreground truncate leading-tight">
{ticket.subject}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
{getStatusIcon(ticket.status)}
<span>{ticket.assignee || 'Unassigned'}</span>
<span></span>
<span>{ticket.createdAt}</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Quick Actions */}
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1 text-xs">
View All Tickets
</Button>
<Button size="sm" className="flex-1 text-xs">
New Ticket
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -1,227 +0,0 @@
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TrendingUp, TrendingDown, BarChart3 } from 'lucide-react';
import { useState } from 'react';
export function UserRetentionChart() {
const [selectedPeriod, setSelectedPeriod] = useState('1W');
const retentionData = {
'1D': {
rate: 22,
trend: '+0.5%',
direction: 'up' as 'up',
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
data: [
[84, 90, 85, 79, 94, 92, 87, 81],
[76, 83, 80, 77, 86, 84, 82, 78],
[63, 70, 68, 66, 73, 71, 69, 67],
[50, 56, 54, 52, 58, 57, 55, 53],
[36, 40, 39, 37, 42, 41, 40, 38],
[23, 26, 25, 24, 27, 26, 25, 24],
[13, 15, 14, 13, 16, 15, 14, 13],
[6, 7, 6, 6, 8, 7, 7, 6]
]
},
'1W': {
rate: 24,
trend: '+2.0%',
direction: 'up' as 'up',
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
data: [
[86, 92, 87, 81, 96, 94, 89, 83],
[78, 85, 82, 79, 88, 86, 84, 80],
[65, 72, 70, 68, 75, 73, 71, 69],
[52, 58, 56, 54, 60, 59, 57, 55],
[38, 42, 41, 39, 44, 43, 42, 40],
[25, 28, 27, 26, 29, 28, 27, 26],
[15, 17, 16, 15, 18, 17, 16, 15],
[8, 9, 8, 8, 10, 9, 9, 8]
]
},
'1M': {
rate: 28,
trend: '+3.2%',
direction: 'up' as 'up',
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
data: [
[88, 94, 89, 83, 98, 96, 91, 85],
[80, 87, 84, 81, 90, 88, 86, 82],
[67, 74, 72, 70, 77, 75, 73, 71],
[54, 60, 58, 56, 62, 61, 59, 57],
[40, 44, 43, 41, 46, 45, 44, 42],
[27, 30, 29, 28, 31, 30, 29, 28],
[17, 19, 18, 17, 20, 19, 18, 17],
[10, 11, 10, 10, 12, 11, 11, 10]
]
},
'3M': {
rate: 31,
trend: '+4.8%',
direction: 'up' as 'up',
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
data: [
[90, 96, 91, 85, 100, 98, 93, 87],
[82, 89, 86, 83, 92, 90, 88, 84],
[69, 76, 74, 72, 79, 77, 75, 73],
[56, 62, 60, 58, 64, 63, 61, 59],
[42, 46, 45, 43, 48, 47, 46, 44],
[29, 32, 31, 30, 33, 32, 31, 30],
[19, 21, 20, 19, 22, 21, 20, 19],
[12, 13, 12, 12, 14, 13, 13, 12]
]
},
'6M': {
rate: 35,
trend: '+6.1%',
direction: 'up' as 'up',
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
data: [
[92, 98, 93, 87, 102, 100, 95, 89],
[84, 91, 88, 85, 94, 92, 90, 86],
[71, 78, 76, 74, 81, 79, 77, 75],
[58, 64, 62, 60, 66, 65, 63, 61],
[44, 48, 47, 45, 50, 49, 48, 46],
[31, 34, 33, 32, 35, 34, 33, 32],
[21, 23, 22, 21, 24, 23, 22, 21],
[14, 15, 14, 14, 16, 15, 15, 14]
]
},
'1Y': {
rate: 41,
trend: '+9.2%',
direction: 'up' as 'up',
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
data: [
[94, 100, 95, 89, 104, 102, 97, 91],
[86, 93, 90, 87, 96, 94, 92, 88],
[73, 80, 78, 76, 83, 81, 79, 77],
[60, 66, 64, 62, 68, 67, 65, 63],
[46, 50, 49, 47, 52, 51, 50, 48],
[33, 36, 35, 34, 37, 36, 35, 34],
[23, 25, 24, 23, 26, 25, 24, 23],
[16, 17, 16, 16, 18, 17, 17, 16]
]
},
};
const currentData = retentionData[selectedPeriod as keyof typeof retentionData];
const getOpacity = (value: number) => (value / 100).toFixed(2);
return (
<Card className="w-full shadow-sm border-0 ring-1 ring-inset ring-border/20">
<CardContent className="p-5 space-y-5">
{/* Header */}
<div className="flex items-start gap-2">
<div className="flex-1">
<div className="text-sm text-muted-foreground font-medium">User Retention</div>
<div className="mt-1 flex items-center gap-2">
<div className="text-2xl font-bold text-foreground">{currentData.rate}%</div>
<Badge
variant={currentData.direction === 'up' ? 'default' : 'destructive'}
className={`h-5 gap-1.5 px-2 text-xs font-medium ${
currentData.direction === 'up'
? 'bg-green-100 text-green-800 border-green-200'
: 'bg-red-100 text-red-800 border-red-200'
}`}
>
{currentData.direction === 'up' ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.trend}
</Badge>
</div>
</div>
<Button variant="outline" size="sm" className="h-7 gap-2.5 px-2 text-xs hover:bg-muted/50 transition-colors">
<BarChart3 className="w-3 h-3" />
Details
</Button>
</div>
{/* Retention Heatmap */}
<div className="relative">
<div
className="h-[194px] w-full border-collapse"
style={{
background: 'linear-gradient(180deg, hsl(var(--border)) 1px, #0000 1px 100%) 0 0 / 100% calc(152px / 4) no-repeat repeat'
}}
>
<table className="-m-px h-full w-full border-collapse" cellPadding="0">
<tbody>
{currentData.data.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((value, colIndex) => (
<td
key={`${rowIndex}-${colIndex}`}
className="p-px"
data-value={value}
>
<div
className="h-full w-full rounded-[1px] bg-primary transition-all duration-200 hover:opacity-100"
style={{ opacity: getOpacity(value) }}
title={`${currentData.weeks[colIndex]}: ${value}%`}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Corner decorations */}
<div className="absolute bottom-6 left-0 z-10 size-4 overflow-hidden">
<div className="size-4 rounded-bl-lg" style={{ boxShadow: '-100px 100px 0 100px hsl(var(--background))' }} />
</div>
<div className="absolute bottom-6 right-0 z-10 size-4 overflow-hidden">
<div className="size-4 rounded-br-lg" style={{ boxShadow: '100px 100px 0 100px hsl(var(--background))' }} />
</div>
</div>
{/* Legend */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="font-medium">Cohort Analysis</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-primary opacity-20 rounded" />
<span>Low</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-primary opacity-60 rounded" />
<span>Medium</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-primary rounded" />
<span>High</span>
</div>
</div>
</div>
{/* Time Period Selector */}
<div className="pt-3 border-t border-border/20">
<div className="flex gap-0.5" role="radiogroup">
{['1D', '1W', '1M', '3M', '6M', '1Y'].map((period) => (
<Button
key={period}
variant={selectedPeriod === period ? 'default' : 'ghost'}
size="sm"
onClick={() => setSelectedPeriod(period)}
className={`h-6 px-3 text-xs first:rounded-l-md last:rounded-r-md transition-colors ${
selectedPeriod === period
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted/50 text-muted-foreground'
}`}
>
{period}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -1,161 +0,0 @@
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TrendingDown, TrendingUp, BarChart3 } from 'lucide-react';
import { useState } from 'react';
export function VisitorChannelsChart() {
const [selectedPeriod, setSelectedPeriod] = useState('1W');
const channelsData = {
'1D': {
overall: 76,
trend: '-0.8%',
direction: 'down' as 'down',
channels: [
{ name: 'Organic Search', percentage: 43, color: 'bg-gray-500', trend: '-1.2%' },
{ name: 'Direct Traffic', percentage: 42, color: 'bg-blue-500', trend: '-0.3%' },
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+0.7%' }
]
},
'1W': {
overall: 78,
trend: '-0.4%',
direction: 'down' as 'down',
channels: [
{ name: 'Organic Search', percentage: 45, color: 'bg-gray-500', trend: '-0.8%' },
{ name: 'Direct Traffic', percentage: 40, color: 'bg-blue-500', trend: '-0.2%' },
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+0.6%' }
]
},
'1M': {
overall: 81,
trend: '+1.2%',
direction: 'up' as 'up',
channels: [
{ name: 'Organic Search', percentage: 47, color: 'bg-gray-500', trend: '+2.1%' },
{ name: 'Direct Traffic', percentage: 38, color: 'bg-blue-500', trend: '+0.9%' },
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+0.6%' }
]
},
'3M': {
overall: 84,
trend: '+2.8%',
direction: 'up' as 'up',
channels: [
{ name: 'Organic Search', percentage: 48, color: 'bg-gray-500', trend: '+4.2%' },
{ name: 'Direct Traffic', percentage: 37, color: 'bg-blue-500', trend: '+1.8%' },
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+2.4%' }
]
},
'6M': {
overall: 86,
trend: '+4.3%',
direction: 'up' as 'up',
channels: [
{ name: 'Organic Search', percentage: 49, color: 'bg-gray-500', trend: '+6.7%' },
{ name: 'Direct Traffic', percentage: 36, color: 'bg-blue-500', trend: '+3.1%' },
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+3.1%' }
]
},
'1Y': {
overall: 89,
trend: '+7.1%',
direction: 'up' as 'up',
channels: [
{ name: 'Organic Search', percentage: 51, color: 'bg-gray-500', trend: '+11.3%' },
{ name: 'Direct Traffic', percentage: 34, color: 'bg-blue-500', trend: '+5.2%' },
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+4.8%' }
]
},
};
const currentData = channelsData[selectedPeriod as keyof typeof channelsData];
return (
<Card className="w-full shadow-sm border-0 ring-1 ring-inset ring-border/20">
<CardContent className="p-5 space-y-5">
{/* Header */}
<div className="flex items-start gap-2">
<div className="flex-1">
<div className="flex items-center gap-1">
<div className="text-sm text-muted-foreground font-medium">Visitors Channels</div>
</div>
<div className="mt-1 flex items-center gap-2">
<div className="text-2xl font-bold text-foreground">{currentData.overall}%</div>
<Badge
variant={currentData.direction === 'up' ? 'default' : 'destructive'}
className={`h-5 gap-1.5 px-2 text-xs font-medium ${
currentData.direction === 'up'
? 'bg-green-100 text-green-800 border-green-200'
: 'bg-red-100 text-red-800 border-red-200'
}`}
>
{currentData.direction === 'up' ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.trend}
</Badge>
</div>
</div>
<Button variant="outline" size="sm" className="h-7 gap-2.5 px-2 text-xs hover:bg-muted/50 transition-colors">
<BarChart3 className="w-3 h-3" />
Details
</Button>
</div>
{/* Chart */}
<div className="flex flex-col gap-5">
<div className="flex gap-[5px]">
{currentData.channels.map((channel, index) => (
<div
key={channel.name}
className="h-2 rounded-sm transition-all duration-300 hover:opacity-80"
style={{ width: `${channel.percentage}%` }}
>
<div
className={`h-full rounded-sm ${channel.color} chart-category-cell-load`}
style={{ '--i': index } as React.CSSProperties}
/>
</div>
))}
</div>
{/* Channel Labels */}
<div className="flex flex-wrap gap-4">
{currentData.channels.map((channel) => (
<div key={channel.name} className="flex items-center gap-1 text-left text-xs text-muted-foreground">
<div className={`w-3 h-3 shrink-0 rounded-full border-2 border-background shadow-sm ${channel.color}`} />
<span className="font-medium">{channel.name}</span>
<span className="font-semibold text-foreground">{channel.percentage}%</span>
</div>
))}
</div>
</div>
{/* Time Period Selector */}
<div className="pt-3 border-t border-border/20">
<div className="flex gap-0.5" role="radiogroup">
{['1D', '1W', '1M', '3M', '6M', '1Y'].map((period) => (
<Button
key={period}
variant={selectedPeriod === period ? 'default' : 'ghost'}
size="sm"
onClick={() => setSelectedPeriod(period)}
className={`h-6 px-3 text-xs first:rounded-l-md last:rounded-r-md transition-colors ${
selectedPeriod === period
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted/50 text-muted-foreground'
}`}
>
{period}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -1,176 +0,0 @@
import { MetricCard } from './MetricCard';
import { Badge } from '@/components/ui/badge';
import { TrendingUp, TrendingDown, Monitor, Smartphone, Tablet } from 'lucide-react';
import { useState } from 'react';
export function VisitorsMetricCard() {
const [selectedPeriod, setSelectedPeriod] = useState('1W');
const visitorsData = {
'1D': {
value: '23,746',
trend: '-1.4%',
direction: 'down' as const,
devices: {
desktop: { percentage: 27, trend: '-3.2%' },
mobile: { percentage: 63, trend: '+0.8%' },
tablet: { percentage: 10, trend: '-1.1%' }
}
},
'1W': {
value: '237,456',
trend: '-1.4%',
direction: 'down' as const,
devices: {
desktop: { percentage: 27, trend: '-3.2%' },
mobile: { percentage: 63, trend: '+0.8%' },
tablet: { percentage: 10, trend: '-1.1%' }
}
},
'1M': {
value: '1,012,847',
trend: '+2.1%',
direction: 'up' as const,
devices: {
desktop: { percentage: 25, trend: '-2.1%' },
mobile: { percentage: 65, trend: '+3.4%' },
tablet: { percentage: 10, trend: '-1.3%' }
}
},
'3M': {
value: '3,047,234',
trend: '+5.8%',
direction: 'up' as const,
devices: {
desktop: { percentage: 24, trend: '-4.2%' },
mobile: { percentage: 66, trend: '+7.1%' },
tablet: { percentage: 10, trend: '-2.9%' }
}
},
'6M': {
value: '6,234,891',
trend: '+8.3%',
direction: 'up' as const,
devices: {
desktop: { percentage: 23, trend: '-5.8%' },
mobile: { percentage: 67, trend: '+11.2%' },
tablet: { percentage: 10, trend: '-5.4%' }
}
},
'1Y': {
value: '12,891,234',
trend: '+12.7%',
direction: 'up' as const,
devices: {
desktop: { percentage: 22, trend: '-8.1%' },
mobile: { percentage: 68, trend: '+18.3%' },
tablet: { percentage: 10, trend: '-10.2%' }
}
},
};
const currentData = visitorsData[selectedPeriod as keyof typeof visitorsData];
return (
<MetricCard
title="Total Visitors"
value={currentData.value}
trend={{
value: currentData.trend,
direction: currentData.direction
}}
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
>
<div className="space-y-4">
{/* Device Breakdown */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Desktop</span>
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">{currentData.devices.desktop.percentage}%</span>
<Badge
variant={currentData.devices.desktop.trend.startsWith('+') ? 'default' : 'destructive'}
className="h-5 gap-1 px-2 text-xs"
>
{currentData.devices.desktop.trend.startsWith('+') ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.devices.desktop.trend}
</Badge>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Smartphone className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Mobile</span>
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">{currentData.devices.mobile.percentage}%</span>
<Badge
variant={currentData.devices.mobile.trend.startsWith('+') ? 'default' : 'destructive'}
className="h-5 gap-1 px-2 text-xs"
>
{currentData.devices.mobile.trend.startsWith('+') ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.devices.mobile.trend}
</Badge>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Tablet className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Tablet</span>
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">{currentData.devices.tablet.percentage}%</span>
<Badge
variant={currentData.devices.tablet.trend.startsWith('+') ? 'default' : 'destructive'}
className="h-5 gap-1 px-2 text-xs"
>
{currentData.devices.tablet.trend.startsWith('+') ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.devices.tablet.trend}
</Badge>
</div>
</div>
</div>
{/* Device Progress Bars */}
<div className="space-y-2">
<div className="h-2 w-full rounded-sm bg-muted overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${currentData.devices.desktop.percentage}%` }}
/>
</div>
<div className="h-2 w-full rounded-sm bg-muted overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${currentData.devices.mobile.percentage}%` }}
/>
</div>
<div className="h-2 w-full rounded-sm bg-muted overflow-hidden">
<div
className="h-full bg-orange-500 transition-all duration-300"
style={{ width: `${currentData.devices.tablet.percentage}%` }}
/>
</div>
</div>
</div>
</MetricCard>
);
}
@@ -1,235 +0,0 @@
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TrendingUp, TrendingDown, BarChart3 } from 'lucide-react';
import { useState } from 'react';
export function WeeklyVisitorsChart() {
const [selectedPeriod, setSelectedPeriod] = useState('1W');
const weeklyData = {
'1D': {
total: 15847,
trend: '+0.3%',
direction: 'up' as 'up',
newVisitors: 9508,
returningVisitors: 6339,
data: {
new: [1200, 1350, 1100, 1400, 1300, 1250, 1400],
returning: [800, 900, 850, 950, 900, 880, 950]
}
},
'1W': {
total: 16008,
trend: '+1.1%',
direction: 'up' as 'up',
newVisitors: 9605,
returningVisitors: 6403,
data: {
new: [1200, 1350, 1100, 1400, 1300, 1250, 1400],
returning: [800, 900, 850, 950, 900, 880, 950]
}
},
'1M': {
total: 16892,
trend: '+2.8%',
direction: 'up' as 'up',
newVisitors: 10135,
returningVisitors: 6757,
data: {
new: [1300, 1450, 1200, 1500, 1400, 1350, 1500],
returning: [850, 950, 900, 1000, 950, 930, 1000]
}
},
'3M': {
total: 18234,
trend: '+4.9%',
direction: 'up' as 'up',
newVisitors: 10940,
returningVisitors: 7294,
data: {
new: [1400, 1550, 1300, 1600, 1500, 1450, 1600],
returning: [900, 1000, 950, 1050, 1000, 980, 1050]
}
},
'6M': {
total: 19876,
trend: '+7.2%',
direction: 'up' as 'up',
newVisitors: 11926,
returningVisitors: 7950,
data: {
new: [1500, 1650, 1400, 1700, 1600, 1550, 1700],
returning: [950, 1050, 1000, 1100, 1050, 1030, 1100]
}
},
'1Y': {
total: 22145,
trend: '+11.3%',
direction: 'up' as 'up',
newVisitors: 13287,
returningVisitors: 8858,
data: {
new: [1650, 1800, 1550, 1850, 1750, 1700, 1850],
returning: [1050, 1150, 1100, 1200, 1150, 1130, 1200]
}
},
};
const currentData = weeklyData[selectedPeriod as keyof typeof weeklyData];
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const maxValue = Math.max(
...currentData.data.new,
...currentData.data.returning
);
return (
<Card className="w-full shadow-sm border-0 ring-1 ring-inset ring-border/20">
<CardContent className="p-5 space-y-4">
{/* Header */}
<div className="flex items-start gap-2">
<div className="flex-1">
<div className="text-sm text-muted-foreground font-medium">Weekly Visitors</div>
<div className="mt-1 flex items-center gap-2">
<div className="text-2xl font-bold text-foreground">{currentData.total.toLocaleString()}</div>
<Badge
variant={currentData.direction === 'up' ? 'default' : 'destructive'}
className={`h-5 gap-1.5 px-2 text-xs font-medium ${
currentData.direction === 'up'
? 'bg-green-100 text-green-800 border-green-200'
: 'bg-red-100 text-red-800 border-red-200'
}`}
>
{currentData.direction === 'up' ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.trend}
</Badge>
</div>
</div>
<Button variant="outline" size="sm" className="h-7 gap-2.5 px-2 text-xs hover:bg-muted/50 transition-colors">
<BarChart3 className="w-3 h-3" />
Details
</Button>
</div>
{/* Legend */}
<div className="flex w-full gap-1.5 rounded-lg bg-muted/50 py-1.5 ring-1 ring-inset ring-border/20">
<div className="flex flex-1 items-center justify-center gap-1">
<div className="flex size-4 shrink-0 items-center justify-center">
<div className="size-3 shrink-0 rounded-full border-2 border-background shadow-sm bg-warning-base" />
</div>
<span className="text-xs text-muted-foreground">New visitors</span>
</div>
<div className="relative w-0 before:absolute before:left-0 before:top-0 before:h-full before:w-px before:bg-border" />
<div className="flex flex-1 items-center justify-center gap-1">
<div className="flex size-4 shrink-0 items-center justify-center">
<div className="size-3 shrink-0 rounded-full border-2 border-background shadow-sm bg-success-base" />
</div>
<span className="text-xs text-muted-foreground">Returning visitors</span>
</div>
</div>
{/* Chart */}
<div className="relative h-40">
{/* Grid lines */}
<div className="absolute inset-0 flex flex-col justify-between">
{[0, 25, 50, 75, 100].map((line) => (
<div
key={line}
className="w-full border-t border-border/20"
style={{ opacity: line === 0 ? 0 : 0.3 }}
/>
))}
</div>
{/* Chart lines */}
<div className="relative h-full w-full">
{/* New visitors line */}
<svg className="absolute inset-0 w-full h-full">
<polyline
points={currentData.data.new.map((value, index) => {
const x = (index / (currentData.data.new.length - 1)) * 100;
const y = 100 - (value / maxValue) * 100;
return `${x}%,${y}%`;
}).join(' ')}
fill="none"
stroke="hsl(var(--warning))"
strokeWidth="2"
className="drop-shadow-sm"
/>
{currentData.data.new.map((value, index) => (
<circle
key={`new-${index}`}
cx={`${(index / (currentData.data.new.length - 1)) * 100}%`}
cy={`${100 - (value / maxValue) * 100}%`}
r="3"
fill="hsl(var(--warning))"
className="hover:r-4 transition-all"
/>
))}
</svg>
{/* Returning visitors line */}
<svg className="absolute inset-0 w-full h-full">
<polyline
points={currentData.data.returning.map((value, index) => {
const x = (index / (currentData.data.returning.length - 1)) * 100;
const y = 100 - (value / maxValue) * 100;
return `${x}%,${y}%`;
}).join(' ')}
fill="none"
stroke="hsl(var(--success))"
strokeWidth="2"
className="drop-shadow-sm"
/>
{currentData.data.returning.map((value, index) => (
<circle
key={`returning-${index}`}
cx={`${(index / (currentData.data.returning.length - 1)) * 100}%`}
cy={`${100 - (value / maxValue) * 100}%`}
r="3"
fill="hsl(var(--success))"
className="hover:r-4 transition-all"
/>
))}
</svg>
</div>
</div>
{/* Day labels */}
<div className="grid auto-cols-fr grid-flow-col gap-0.5 px-4 py-3 text-center">
{days.map((day) => (
<div key={day} className="text-xs text-muted-foreground">
{day}
</div>
))}
</div>
{/* Time Period Selector */}
<div className="pt-3 border-t border-border/20">
<div className="flex gap-0.5" role="radiogroup">
{['1D', '1W', '1M', '3M', '6M', '1Y'].map((period) => (
<Button
key={period}
variant={selectedPeriod === period ? 'default' : 'ghost'}
size="sm"
onClick={() => setSelectedPeriod(period)}
className={`h-6 px-3 text-xs first:rounded-l-md last:rounded-r-md transition-colors ${
selectedPeriod === period
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted/50 text-muted-foreground'
}`}
>
{period}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
);
}
+1
View File
@@ -0,0 +1 @@
export {};
+90
View File
@@ -0,0 +1,90 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Clock, RefreshCw, Download, Trash2, Plus, HardDrive, Calendar, AlertTriangle, CheckCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { api } from '@/lib/api';
import { formatDistanceToNow, format } from 'date-fns';
function BackupManager({ databaseId, databaseName: _databaseName }) {
const [selectedBackup, setSelectedBackup] = useState(null);
const queryClient = useQueryClient();
const { data: backups, isLoading } = useQuery({
queryKey: ['backups', databaseId],
queryFn: async () => {
const response = await api.get(`/api/v1/databases/${databaseId}/backups`);
return response.backups;
},
refetchInterval: 30000,
});
const createBackup = useMutation({
mutationFn: async () => {
const response = await api.post(`/api/v1/databases/${databaseId}/backup`, {});
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
},
});
const restoreBackup = useMutation({
mutationFn: async (backupId) => {
const response = await api.post(`/api/v1/databases/${databaseId}/restore`, {
backup_id: backupId,
});
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
setSelectedBackup(null);
},
});
const deleteBackup = useMutation({
mutationFn: async (backupId) => {
const response = await api.delete(`/api/v1/backups/${backupId}`);
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
},
});
const formatSize = (bytes) => {
if (bytes < 1024)
return `${bytes} B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return _jsx(CheckCircle, { className: "w-4 h-4 text-green-500" });
case 'failed':
return _jsx(AlertTriangle, { className: "w-4 h-4 text-red-500" });
case 'in_progress':
return _jsx(Loader2, { className: "w-4 h-4 text-blue-500 animate-spin" });
default:
return _jsx(Clock, { className: "w-4 h-4 text-gray-500" });
}
};
const getStatusColor = (status) => {
switch (status) {
case 'completed':
return 'bg-green-500';
case 'failed':
return 'bg-red-500';
case 'in_progress':
return 'bg-blue-500';
default:
return 'bg-gray-500';
}
};
if (isLoading) {
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "flex items-center justify-center", children: _jsx(Loader2, { className: "w-6 h-6 animate-spin text-muted-foreground" }) }) }) }));
}
const totalSize = backups?.reduce((sum, b) => sum + b.size_bytes, 0) || 0;
const completedBackups = backups?.filter((b) => b.status === 'completed').length || 0;
return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-semibold", children: "Backups" }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [completedBackups, " backups \u2022 ", formatSize(totalSize), " total"] })] }), _jsxs(Button, { onClick: () => createBackup.mutate(), disabled: createBackup.isPending, children: [createBackup.isPending ? (_jsx(Loader2, { className: "w-4 h-4 mr-2 animate-spin" })) : (_jsx(Plus, { className: "w-4 h-4 mr-2" })), "Create Backup"] })] }), !backups || backups.length === 0 ? (_jsx(Card, { children: _jsxs(CardContent, { className: "p-6 text-center text-muted-foreground", children: [_jsx(HardDrive, { className: "w-12 h-12 mx-auto mb-2 opacity-50" }), _jsx("p", { children: "No backups yet" }), _jsx("p", { className: "text-sm", children: "Create your first backup to protect your data" })] }) })) : (_jsx("div", { className: "space-y-2", children: backups.map((backup) => (_jsx(Card, { className: selectedBackup === backup.id ? 'border-primary' : '', children: _jsxs(CardContent, { className: "p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [getStatusIcon(backup.status), _jsxs("div", { children: [_jsx("div", { className: "font-medium", children: backup.name }), _jsxs("div", { className: "flex items-center gap-3 text-sm text-muted-foreground", children: [_jsxs("span", { className: "flex items-center gap-1", children: [_jsx(Calendar, { className: "w-3 h-3" }), formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })] }), _jsx("span", { children: formatSize(backup.size_bytes) })] })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: `${getStatusColor(backup.status)} text-white`, children: backup.status }), backup.status === 'completed' && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => restoreBackup.mutate(backup.id), disabled: restoreBackup.isPending, children: restoreBackup.isPending ? (_jsx(Loader2, { className: "w-4 h-4 animate-spin" })) : (_jsx(RefreshCw, { className: "w-4 h-4" })) }), _jsx(Button, { variant: "ghost", size: "sm", children: _jsx(Download, { className: "w-4 h-4" }) })] })), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => deleteBackup.mutate(backup.id), disabled: deleteBackup.isPending, className: "text-destructive hover:text-destructive", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })] }), backup.completed_at && (_jsxs("div", { className: "mt-2 text-xs text-muted-foreground", children: ["Completed: ", format(new Date(backup.completed_at), 'PPpp'), backup.expires_at && (_jsxs("span", { className: "ml-2", children: ["\u2022 Expires: ", format(new Date(backup.expires_at), 'PP')] }))] }))] }) }, backup.id))) })), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-sm", children: "Backup Schedule" }) }), _jsxs(CardContent, { children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Clock, { className: "w-4 h-4 text-muted-foreground" }), _jsx("span", { className: "text-sm", children: "Daily backups at 2:00 AM UTC" })] }), _jsx(Button, { variant: "outline", size: "sm", children: "Configure" })] }), _jsx("p", { className: "text-xs text-muted-foreground mt-2", children: "Backups are retained for 30 days by default" })] })] })] }));
}
+248
View File
@@ -0,0 +1,248 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Clock,
RefreshCw,
Download,
Trash2,
Plus,
HardDrive,
Calendar,
AlertTriangle,
CheckCircle,
Loader2
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { api } from '@/lib/api';
import { formatDistanceToNow, format } from 'date-fns';
interface Backup {
id: string;
database_id: string;
name: string;
size_bytes: number;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
created_at: string;
completed_at: string | null;
expires_at: string | null;
}
interface BackupManagerProps {
databaseId: string;
databaseName: string;
}
function _BackupManager({ databaseId, databaseName: _databaseName }: BackupManagerProps) {
const [selectedBackup, setSelectedBackup] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data: backups, isLoading } = useQuery({
queryKey: ['backups', databaseId],
queryFn: async () => {
const response = await api.get<{ backups: Backup[] }>(`/api/v1/databases/${databaseId}/backups`);
return response.backups;
},
refetchInterval: 30000,
});
const createBackup = useMutation({
mutationFn: async () => {
const response = await api.post<{ backup: Backup }>(`/api/v1/databases/${databaseId}/backup`, {});
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
},
});
const restoreBackup = useMutation({
mutationFn: async (backupId: string) => {
const response = await api.post<{ message: string }>(`/api/v1/databases/${databaseId}/restore`, {
backup_id: backupId,
});
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
setSelectedBackup(null);
},
});
const deleteBackup = useMutation({
mutationFn: async (backupId: string) => {
const response = await api.delete<{ message: string }>(`/api/v1/backups/${backupId}`);
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
},
});
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'failed':
return <AlertTriangle className="w-4 h-4 text-red-500" />;
case 'in_progress':
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />;
default:
return <Clock className="w-4 h-4 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-500';
case 'failed':
return 'bg-red-500';
case 'in_progress':
return 'bg-blue-500';
default:
return 'bg-gray-500';
}
};
if (isLoading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
const totalSize = backups?.reduce((sum, b) => sum + b.size_bytes, 0) || 0;
const completedBackups = backups?.filter((b) => b.status === 'completed').length || 0;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Backups</h3>
<p className="text-sm text-muted-foreground">
{completedBackups} backups {formatSize(totalSize)} total
</p>
</div>
<Button onClick={() => createBackup.mutate()} disabled={createBackup.isPending}>
{createBackup.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Create Backup
</Button>
</div>
{!backups || backups.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
<HardDrive className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No backups yet</p>
<p className="text-sm">Create your first backup to protect your data</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{backups.map((backup) => (
<Card key={backup.id} className={selectedBackup === backup.id ? 'border-primary' : ''}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getStatusIcon(backup.status)}
<div>
<div className="font-medium">{backup.name}</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
</span>
<span>{formatSize(backup.size_bytes)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className={`${getStatusColor(backup.status)} text-white`}>
{backup.status}
</Badge>
{backup.status === 'completed' && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => restoreBackup.mutate(backup.id)}
disabled={restoreBackup.isPending}
>
{restoreBackup.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
</Button>
<Button variant="ghost" size="sm">
<Download className="w-4 h-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => deleteBackup.mutate(backup.id)}
disabled={deleteBackup.isPending}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{backup.completed_at && (
<div className="mt-2 text-xs text-muted-foreground">
Completed: {format(new Date(backup.completed_at), 'PPpp')}
{backup.expires_at && (
<span className="ml-2">
Expires: {format(new Date(backup.expires_at), 'PP')}
</span>
)}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-sm">Backup Schedule</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">Daily backups at 2:00 AM UTC</span>
</div>
<Button variant="outline" size="sm">
Configure
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Backups are retained for 30 days by default
</p>
</CardContent>
</Card>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
interface DatabaseDetailPanelProps {
databaseId: string;
onClose: () => void;
}
export default function DatabaseDetailPanel({ databaseId, onClose: _onClose }: DatabaseDetailPanelProps): import("react/jsx-runtime").JSX.Element;
export {};
File diff suppressed because one or more lines are too long
@@ -13,8 +13,6 @@ import {
Pause, Pause,
RefreshCw, RefreshCw,
Download, Download,
Upload,
Settings,
Activity, Activity,
HardDrive, HardDrive,
MemoryStick, MemoryStick,
@@ -24,7 +22,6 @@ import {
Copy, Copy,
Eye, Eye,
EyeOff, EyeOff,
Trash2,
RotateCcw, RotateCcw,
BarChart3, BarChart3,
Users, Users,
@@ -134,7 +131,7 @@ const mockDatabaseDetail: DatabaseDetail = {
} }
}; };
export default function DatabaseDetailPanel({ databaseId, onClose }: DatabaseDetailPanelProps) { export default function DatabaseDetailPanel({ databaseId, onClose: _onClose }: DatabaseDetailPanelProps) {
const [showConnectionUrl, setShowConnectionUrl] = useState(false); const [showConnectionUrl, setShowConnectionUrl] = useState(false);
const [isRestoring, setIsRestoring] = useState(false); const [isRestoring, setIsRestoring] = useState(false);
const [selectedBackup, setSelectedBackup] = useState<string | null>(null); const [selectedBackup, setSelectedBackup] = useState<string | null>(null);
@@ -148,7 +145,7 @@ export default function DatabaseDetailPanel({ databaseId, onClose }: DatabaseDet
}); });
const toggleDatabaseMutation = useMutation({ const toggleDatabaseMutation = useMutation({
mutationFn: ({ action }: { action: 'start' | 'stop' | 'restart' }) => { mutationFn: ({ action: _action }: { action: 'start' | 'stop' | 'restart' }) => {
return new Promise(resolve => setTimeout(resolve, 1000)); return new Promise(resolve => setTimeout(resolve, 1000));
}, },
onSuccess: () => { onSuccess: () => {
@@ -166,7 +163,7 @@ export default function DatabaseDetailPanel({ databaseId, onClose }: DatabaseDet
}); });
const restoreBackupMutation = useMutation({ const restoreBackupMutation = useMutation({
mutationFn: (backupId: string) => { mutationFn: (_backupId: string) => {
return new Promise(resolve => setTimeout(resolve, 5000)); return new Promise(resolve => setTimeout(resolve, 5000));
}, },
onSuccess: () => { onSuccess: () => {
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,86 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, RotateCcw, Clock, CheckCircle, XCircle, Loader2, ChevronDown, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { formatDistanceToNow } from 'date-fns';
import { deploymentsApi } from '@/lib/api';
const statusConfig = {
pending: { color: 'bg-gray-500', icon: Clock, label: 'Pending' },
building: { color: 'bg-blue-500', icon: Loader2, label: 'Building', animate: true },
deploying: { color: 'bg-yellow-500', icon: Loader2, label: 'Deploying', animate: true },
deployed: { color: 'bg-green-500', icon: CheckCircle, label: 'Deployed' },
failed: { color: 'bg-red-500', icon: XCircle, label: 'Failed' },
rolling_back: { color: 'bg-orange-500', icon: RotateCcw, label: 'Rolling Back', animate: true },
};
function DeploymentsPanel({ serviceId, serviceName: _serviceName }) {
const [expandedDeployment, setExpandedDeployment] = useState(null);
const queryClient = useQueryClient();
const { data: deployments, isLoading } = useQuery({
queryKey: ['deployments', serviceId],
queryFn: async () => {
const response = await deploymentsApi.getDeployments(serviceId);
return response.deployments;
},
refetchInterval: 5000,
});
const createDeployment = useMutation({
mutationFn: async (data) => {
const response = await deploymentsApi.createDeployment(serviceId, {
trigger: 'manual',
...data,
});
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
},
});
const rollbackDeployment = useMutation({
mutationFn: async (deploymentId) => {
const response = await deploymentsApi.rollbackDeployment(deploymentId);
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
},
});
if (isLoading) {
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "flex items-center justify-center", children: _jsx(Loader2, { className: "w-6 h-6 animate-spin text-muted-foreground" }) }) }) }));
}
return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h3", { className: "text-lg font-semibold", children: "Deployments" }), _jsxs(Button, { onClick: () => createDeployment.mutate({}), disabled: createDeployment.isPending, size: "sm", children: [createDeployment.isPending ? (_jsx(Loader2, { className: "w-4 h-4 mr-2 animate-spin" })) : (_jsx(Play, { className: "w-4 h-4 mr-2" })), "Deploy"] })] }), !deployments || deployments.length === 0 ? (_jsx(Card, { children: _jsx(CardContent, { className: "p-6 text-center text-muted-foreground", children: "No deployments yet. Click \"Deploy\" to create your first deployment." }) })) : (_jsx("div", { className: "space-y-2", children: deployments.map((deployment) => {
const config = statusConfig[deployment.status] || statusConfig.pending;
const StatusIcon = config.icon;
const isExpanded = expandedDeployment === deployment.id;
return (_jsx(Collapsible, { open: isExpanded, onOpenChange: () => setExpandedDeployment(isExpanded ? null : deployment.id), children: _jsxs(Card, { className: isExpanded ? 'border-primary' : '', children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsx(CardHeader, { className: "cursor-pointer hover:bg-muted/50 transition-colors py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: `p-2 rounded-full ${config.color}`, children: _jsx(StatusIcon, { className: `w-4 h-4 text-white ${config.animate ? 'animate-spin' : ''}` }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-medium", children: deployment.commit_hash
? deployment.commit_hash.slice(0, 7)
: 'Manual Deploy' }), _jsx(Badge, { variant: "outline", className: "text-xs", children: config.label })] }), _jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Clock, { className: "w-3 h-3" }), formatDistanceToNow(new Date(deployment.created_at), {
addSuffix: true,
})] })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [deployment.status === 'deployed' && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: (e) => {
e.stopPropagation();
rollbackDeployment.mutate(deployment.id);
}, disabled: rollbackDeployment.isPending, children: [_jsx(RotateCcw, { className: "w-4 h-4 mr-1" }), "Rollback"] })), _jsx(ChevronDown, { className: `w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}` })] })] }) }) }), _jsx(CollapsibleContent, { children: _jsx(CardContent, { className: "pt-0 pb-4", children: _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "grid grid-cols-2 gap-4 text-sm", children: [_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Image:" }), _jsxs("span", { className: "ml-2 font-mono", children: [deployment.image_name, ":", deployment.image_tag] })] }), deployment.commit_hash && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Commit:" }), _jsx("span", { className: "ml-2 font-mono", children: deployment.commit_hash })] })), deployment.started_at && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Started:" }), _jsx("span", { className: "ml-2", children: new Date(deployment.started_at).toLocaleString() })] })), deployment.completed_at && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Completed:" }), _jsx("span", { className: "ml-2", children: new Date(deployment.completed_at).toLocaleString() })] }))] }), deployment.error && (_jsxs("div", { className: "p-3 bg-destructive/10 rounded-md", children: [_jsx("p", { className: "text-sm text-destructive font-medium", children: "Error:" }), _jsx("p", { className: "text-sm text-destructive/80 mt-1", children: deployment.error })] })), _jsx(DeploymentLogs, { deploymentId: deployment.id })] }) }) })] }) }, deployment.id));
}) }))] }));
}
function DeploymentLogs({ deploymentId }) {
const [activeTab, setActiveTab] = useState('build');
const { data: logs, isLoading } = useQuery({
queryKey: ['deployment-logs', deploymentId],
queryFn: async () => {
const response = await deploymentsApi.getDeployment(deploymentId);
return response.deployment;
},
});
if (isLoading) {
return (_jsx("div", { className: "flex items-center justify-center p-4", children: _jsx(Loader2, { className: "w-4 h-4 animate-spin" }) }));
}
const currentLogs = activeTab === 'build' ? logs?.build_log : logs?.runtime_log;
return (_jsxs("div", { className: "border rounded-md", children: [_jsxs("div", { className: "flex border-b", children: [_jsxs("button", { onClick: () => setActiveTab('build'), className: `flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${activeTab === 'build'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'}`, children: [_jsx(Terminal, { className: "w-4 h-4" }), "Build Logs"] }), _jsxs("button", { onClick: () => setActiveTab('runtime'), className: `flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${activeTab === 'runtime'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'}`, children: [_jsx(Terminal, { className: "w-4 h-4" }), "Runtime Logs"] })] }), _jsx("div", { className: "p-4 bg-muted/30 max-h-64 overflow-auto", children: _jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap", children: currentLogs || 'No logs available' }) })] }));
}
@@ -0,0 +1,309 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Play,
RotateCcw,
Clock,
CheckCircle,
XCircle,
Loader2,
ChevronDown,
Terminal
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { formatDistanceToNow } from 'date-fns';
import { deploymentsApi } from '@/lib/api';
interface Deployment {
id: string;
service_id: string;
commit_hash: string | null;
status: 'pending' | 'building' | 'deploying' | 'deployed' | 'failed' | 'rolling_back';
image_name: string;
image_tag: string;
build_log: string;
runtime_log: string;
error: string | null;
started_at: string | null;
completed_at: string | null;
created_at: string;
}
interface DeploymentsPanelProps {
serviceId: string;
serviceName: string;
}
interface StatusConfig {
color: string;
icon: typeof Clock;
label: string;
animate?: boolean;
}
const statusConfig: Record<string, StatusConfig> = {
pending: { color: 'bg-gray-500', icon: Clock, label: 'Pending' },
building: { color: 'bg-blue-500', icon: Loader2, label: 'Building', animate: true },
deploying: { color: 'bg-yellow-500', icon: Loader2, label: 'Deploying', animate: true },
deployed: { color: 'bg-green-500', icon: CheckCircle, label: 'Deployed' },
failed: { color: 'bg-red-500', icon: XCircle, label: 'Failed' },
rolling_back: { color: 'bg-orange-500', icon: RotateCcw, label: 'Rolling Back', animate: true },
};
function _DeploymentsPanel({ serviceId, serviceName: _serviceName }: DeploymentsPanelProps) {
const [expandedDeployment, setExpandedDeployment] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data: deployments, isLoading } = useQuery({
queryKey: ['deployments', serviceId],
queryFn: async () => {
const response = await deploymentsApi.getDeployments(serviceId);
return response.deployments as Deployment[];
},
refetchInterval: 5000,
});
const createDeployment = useMutation({
mutationFn: async (data: { commit_hash?: string; branch?: string }) => {
const response = await deploymentsApi.createDeployment(serviceId, {
trigger: 'manual',
...data,
});
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
},
});
const rollbackDeployment = useMutation({
mutationFn: async (deploymentId: string) => {
const response = await deploymentsApi.rollbackDeployment(deploymentId);
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
},
});
if (isLoading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Deployments</h3>
<Button
onClick={() => createDeployment.mutate({})}
disabled={createDeployment.isPending}
size="sm"
>
{createDeployment.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Play className="w-4 h-4 mr-2" />
)}
Deploy
</Button>
</div>
{!deployments || deployments.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
No deployments yet. Click "Deploy" to create your first deployment.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{deployments.map((deployment) => {
const config = statusConfig[deployment.status] || statusConfig.pending;
const StatusIcon = config.icon;
const isExpanded = expandedDeployment === deployment.id;
return (
<Collapsible
key={deployment.id}
open={isExpanded}
onOpenChange={() => setExpandedDeployment(isExpanded ? null : deployment.id)}
>
<Card className={isExpanded ? 'border-primary' : ''}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${config.color}`}>
<StatusIcon
className={`w-4 h-4 text-white ${
config.animate ? 'animate-spin' : ''
}`}
/>
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">
{deployment.commit_hash
? deployment.commit_hash.slice(0, 7)
: 'Manual Deploy'}
</span>
<Badge variant="outline" className="text-xs">
{config.label}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(deployment.created_at), {
addSuffix: true,
})}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{deployment.status === 'deployed' && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
rollbackDeployment.mutate(deployment.id);
}}
disabled={rollbackDeployment.isPending}
>
<RotateCcw className="w-4 h-4 mr-1" />
Rollback
</Button>
)}
<ChevronDown
className={`w-4 h-4 transition-transform ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</div>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0 pb-4">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Image:</span>
<span className="ml-2 font-mono">
{deployment.image_name}:{deployment.image_tag}
</span>
</div>
{deployment.commit_hash && (
<div>
<span className="text-muted-foreground">Commit:</span>
<span className="ml-2 font-mono">
{deployment.commit_hash}
</span>
</div>
)}
{deployment.started_at && (
<div>
<span className="text-muted-foreground">Started:</span>
<span className="ml-2">
{new Date(deployment.started_at).toLocaleString()}
</span>
</div>
)}
{deployment.completed_at && (
<div>
<span className="text-muted-foreground">Completed:</span>
<span className="ml-2">
{new Date(deployment.completed_at).toLocaleString()}
</span>
</div>
)}
</div>
{deployment.error && (
<div className="p-3 bg-destructive/10 rounded-md">
<p className="text-sm text-destructive font-medium">Error:</p>
<p className="text-sm text-destructive/80 mt-1">
{deployment.error}
</p>
</div>
)}
<DeploymentLogs deploymentId={deployment.id} />
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
})}
</div>
)}
</div>
);
}
function DeploymentLogs({ deploymentId }: { deploymentId: string }) {
const [activeTab, setActiveTab] = useState<'build' | 'runtime'>('build');
const { data: logs, isLoading } = useQuery({
queryKey: ['deployment-logs', deploymentId],
queryFn: async () => {
const response = await deploymentsApi.getDeployment(deploymentId);
return response.deployment;
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
);
}
const currentLogs = activeTab === 'build' ? logs?.build_log : logs?.runtime_log;
return (
<div className="border rounded-md">
<div className="flex border-b">
<button
onClick={() => setActiveTab('build')}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'build'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Terminal className="w-4 h-4" />
Build Logs
</button>
<button
onClick={() => setActiveTab('runtime')}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'runtime'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Terminal className="w-4 h-4" />
Runtime Logs
</button>
</div>
<div className="p-4 bg-muted/30 max-h-64 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap">
{currentLogs || 'No logs available'}
</pre>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,65 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Save, Eye, EyeOff, Key, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { variablesApi } from '@/lib/api';
function EnvVariablesEditor({ serviceId }) {
const [variables, setVariables] = useState([]);
const [showSecrets, setShowSecrets] = useState({});
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const { data: existingVars, isLoading } = useQuery({
queryKey: ['variables', serviceId],
queryFn: async () => {
const response = await variablesApi.getVariables(serviceId);
return response.variables;
},
});
useState(() => {
if (existingVars) {
setVariables(existingVars.map((v) => ({
key: v.key,
value: v.is_secret ? '' : v.value,
is_secret: v.is_secret,
})));
}
});
const updateVariables = useMutation({
mutationFn: async (vars) => {
const response = await variablesApi.updateVariables(serviceId, vars);
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['variables', serviceId] });
setHasChanges(false);
},
});
const addVariable = () => {
setVariables([...variables, { key: '', value: '', is_secret: false }]);
setHasChanges(true);
};
const removeVariable = (index) => {
setVariables(variables.filter((_, i) => i !== index));
setHasChanges(true);
};
const updateVariable = (index, field, newValue) => {
const updated = [...variables];
updated[index] = { ...updated[index], [field]: newValue };
setVariables(updated);
setHasChanges(true);
};
const handleSave = () => {
const validVars = variables.filter((v) => v.key.trim() !== '');
updateVariables.mutate(validVars);
};
if (isLoading) {
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "flex items-center justify-center", children: _jsx(Loader2, { className: "w-6 h-6 animate-spin text-muted-foreground" }) }) }) }));
}
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx(CardTitle, { className: "text-lg", children: "Environment Variables" }), _jsxs("div", { className: "flex gap-2", children: [_jsxs(Button, { variant: "outline", size: "sm", onClick: addVariable, children: [_jsx(Plus, { className: "w-4 h-4 mr-1" }), "Add Variable"] }), hasChanges && (_jsxs(Button, { size: "sm", onClick: handleSave, disabled: updateVariables.isPending, children: [updateVariables.isPending ? (_jsx(Loader2, { className: "w-4 h-4 mr-1 animate-spin" })) : (_jsx(Save, { className: "w-4 h-4 mr-1" })), "Save Changes"] }))] })] }) }), _jsxs(CardContent, { children: [_jsx("div", { className: "space-y-3", children: variables.length === 0 ? (_jsx("div", { className: "text-center py-8 text-muted-foreground", children: "No environment variables configured. Click \"Add Variable\" to add one." })) : (variables.map((variable, index) => (_jsxs("div", { className: "flex items-center gap-2 group", children: [_jsxs("div", { className: "flex-1 grid grid-cols-2 gap-2", children: [_jsx(Input, { placeholder: "KEY", value: variable.key, onChange: (e) => updateVariable(index, 'key', e.target.value), className: "font-mono" }), _jsxs("div", { className: "relative", children: [_jsx(Input, { type: variable.is_secret && !showSecrets[index] ? 'password' : 'text', placeholder: "value", value: variable.value, onChange: (e) => updateVariable(index, 'value', e.target.value), className: "font-mono pr-16" }), variable.is_secret && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0", onClick: () => setShowSecrets({
...showSecrets,
[index]: !showSecrets[index],
}), children: showSecrets[index] ? (_jsx(EyeOff, { className: "w-4 h-4" })) : (_jsx(Eye, { className: "w-4 h-4" })) }))] })] }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => updateVariable(index, 'is_secret', !variable.is_secret), className: variable.is_secret ? 'text-amber-500' : 'text-muted-foreground', title: variable.is_secret ? 'Secret (hidden)' : 'Regular variable', children: _jsx(Key, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => removeVariable(index), className: "text-destructive opacity-0 group-hover:opacity-100 transition-opacity", children: _jsx(Trash2, { className: "w-4 h-4" }) })] }, index)))) }), variables.length > 0 && (_jsxs("div", { className: "mt-4 p-3 bg-muted/30 rounded-md text-sm text-muted-foreground", children: [_jsx("p", { className: "font-medium mb-1", children: "Tips:" }), _jsxs("ul", { className: "list-disc list-inside space-y-1 text-xs", children: [_jsx("li", { children: "Secret variables are encrypted and hidden in the UI" }), _jsx("li", { children: "Changes are applied after the next deployment" }), _jsx("li", { children: "Use uppercase keys with underscores (e.g., DATABASE_URL)" })] })] }))] })] }));
}
@@ -0,0 +1,204 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Save, Eye, EyeOff, Key, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { variablesApi } from '@/lib/api';
interface EnvironmentVariable {
id: string;
service_id: string;
key: string;
value: string;
is_secret: boolean;
created_at: string;
updated_at: string;
}
interface EnvVariablesEditorProps {
serviceId: string;
}
function _EnvVariablesEditor({ serviceId }: EnvVariablesEditorProps) {
const [variables, setVariables] = useState<{ key: string; value: string; is_secret: boolean }[]>([]);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const { data: existingVars, isLoading } = useQuery({
queryKey: ['variables', serviceId],
queryFn: async () => {
const response = await variablesApi.getVariables(serviceId);
return response.variables as EnvironmentVariable[];
},
});
useState(() => {
if (existingVars) {
setVariables(
existingVars.map((v) => ({
key: v.key,
value: v.is_secret ? '' : v.value,
is_secret: v.is_secret,
}))
);
}
});
const updateVariables = useMutation({
mutationFn: async (vars: { key: string; value: string; is_secret: boolean }[]) => {
const response = await variablesApi.updateVariables(serviceId, vars);
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['variables', serviceId] });
setHasChanges(false);
},
});
const addVariable = () => {
setVariables([...variables, { key: '', value: '', is_secret: false }]);
setHasChanges(true);
};
const removeVariable = (index: number) => {
setVariables(variables.filter((_, i) => i !== index));
setHasChanges(true);
};
const updateVariable = (
index: number,
field: 'key' | 'value' | 'is_secret',
newValue: string | boolean
) => {
const updated = [...variables];
updated[index] = { ...updated[index], [field]: newValue };
setVariables(updated);
setHasChanges(true);
};
const handleSave = () => {
const validVars = variables.filter((v) => v.key.trim() !== '');
updateVariables.mutate(validVars);
};
if (isLoading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Environment Variables</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={addVariable}>
<Plus className="w-4 h-4 mr-1" />
Add Variable
</Button>
{hasChanges && (
<Button size="sm" onClick={handleSave} disabled={updateVariables.isPending}>
{updateVariables.isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1" />
)}
Save Changes
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{variables.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No environment variables configured. Click "Add Variable" to add one.
</div>
) : (
variables.map((variable, index) => (
<div key={index} className="flex items-center gap-2 group">
<div className="flex-1 grid grid-cols-2 gap-2">
<Input
placeholder="KEY"
value={variable.key}
onChange={(e) => updateVariable(index, 'key', e.target.value)}
className="font-mono"
/>
<div className="relative">
<Input
type={
variable.is_secret && !showSecrets[index] ? 'password' : 'text'
}
placeholder="value"
value={variable.value}
onChange={(e) => updateVariable(index, 'value', e.target.value)}
className="font-mono pr-16"
/>
{variable.is_secret && (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
onClick={() =>
setShowSecrets({
...showSecrets,
[index]: !showSecrets[index],
})
}
>
{showSecrets[index] ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => updateVariable(index, 'is_secret', !variable.is_secret)}
className={variable.is_secret ? 'text-amber-500' : 'text-muted-foreground'}
title={variable.is_secret ? 'Secret (hidden)' : 'Regular variable'}
>
<Key className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeVariable(index)}
className="text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))
)}
</div>
{variables.length > 0 && (
<div className="mt-4 p-3 bg-muted/30 rounded-md text-sm text-muted-foreground">
<p className="font-medium mb-1">Tips:</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>Secret variables are encrypted and hidden in the UI</li>
<li>Changes are applied after the next deployment</li>
<li>Use uppercase keys with underscores (e.g., DATABASE_URL)</li>
</ul>
</div>
)}
</CardContent>
</Card>
);
}
+1
View File
@@ -0,0 +1 @@
export {};
+106
View File
@@ -0,0 +1,106 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Play, Pause, Download, Trash2, Loader2, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { logsApi } from '@/lib/api';
function ServiceLogs({ serviceId, serviceName }) {
const [isStreaming, setIsStreaming] = useState(false);
const [logs, setLogs] = useState([]);
const [autoScroll, setAutoScroll] = useState(true);
const logContainerRef = useRef(null);
const eventSourceRef = useRef(null);
const { data: initialLogs, isLoading } = useQuery({
queryKey: ['logs', serviceId],
queryFn: async () => {
const response = await logsApi.getServiceLogs(serviceId, { lines: 100 });
return response.logs.map((log) => ({
timestamp: log.timestamp,
message: log.message,
stream: log.stream,
}));
},
});
useEffect(() => {
if (initialLogs) {
setLogs(initialLogs);
}
}, [initialLogs]);
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, []);
const startStreaming = () => {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
const _token = localStorage.getItem('auth_token');
const url = new URL(`${API_BASE_URL}/api/v1/services/${serviceId}/logs`);
url.searchParams.append('follow', 'true');
const eventSource = new EventSource(url.toString(), {
withCredentials: true,
});
eventSource.onmessage = (event) => {
try {
const log = JSON.parse(event.data);
setLogs((prev) => [...prev.slice(-500), log]);
}
catch (e) {
console.error('Failed to parse log:', e);
}
};
eventSource.onerror = () => {
console.error('EventSource error');
eventSource.close();
setIsStreaming(false);
};
eventSourceRef.current = eventSource;
setIsStreaming(true);
};
const stopStreaming = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setIsStreaming(false);
};
const clearLogs = () => {
setLogs([]);
};
const downloadLogs = () => {
const content = logs
.map((log) => `[${log.timestamp}] ${log.message}`)
.join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${serviceName}-${new Date().toISOString()}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleScroll = () => {
if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
setAutoScroll(isAtBottom);
}
};
if (isLoading) {
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "flex items-center justify-center", children: _jsx(Loader2, { className: "w-6 h-6 animate-spin text-muted-foreground" }) }) }) }));
}
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs(CardTitle, { className: "text-lg flex items-center gap-2", children: [_jsx(Terminal, { className: "w-5 h-5" }), "Service Logs"] }), _jsxs("div", { className: "flex items-center gap-2", children: [isStreaming ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: stopStreaming, children: [_jsx(Pause, { className: "w-4 h-4 mr-1" }), "Stop"] })) : (_jsxs(Button, { variant: "outline", size: "sm", onClick: startStreaming, children: [_jsx(Play, { className: "w-4 h-4 mr-1" }), "Stream"] })), _jsxs(Button, { variant: "outline", size: "sm", onClick: downloadLogs, children: [_jsx(Download, { className: "w-4 h-4 mr-1" }), "Download"] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: clearLogs, children: [_jsx(Trash2, { className: "w-4 h-4 mr-1" }), "Clear"] })] })] }) }), _jsxs(CardContent, { children: [_jsx("div", { ref: logContainerRef, onScroll: handleScroll, className: "bg-gray-950 text-gray-100 rounded-md p-4 h-96 overflow-auto font-mono text-sm", children: logs.length === 0 ? (_jsx("div", { className: "text-gray-500 text-center py-8", children: "No logs available. Start the service or enable streaming to see logs." })) : (logs.map((log, index) => (_jsxs("div", { className: `py-0.5 ${log.stream === 'stderr'
? 'text-red-400'
: log.stream === 'system'
? 'text-yellow-400'
: 'text-gray-300'}`, children: [_jsxs("span", { className: "text-gray-600 mr-2", children: ["[", new Date(log.timestamp).toLocaleTimeString(), "]"] }), log.message] }, index)))) }), _jsxs("div", { className: "mt-2 flex items-center justify-between text-xs text-muted-foreground", children: [_jsxs("span", { children: [logs.length, " log entries", autoScroll && ' • Auto-scroll enabled'] }), isStreaming && (_jsxs("span", { className: "flex items-center gap-1 text-green-500", children: [_jsx("span", { className: "w-2 h-2 bg-green-500 rounded-full animate-pulse" }), "Streaming..."] }))] })] })] }));
}
+212
View File
@@ -0,0 +1,212 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Play, Pause, Download, Trash2, Loader2, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { logsApi } from '@/lib/api';
interface LogEntry {
timestamp: string;
message: string;
stream: 'stdout' | 'stderr' | 'system';
}
interface ServiceLogsProps {
serviceId: string;
serviceName: string;
}
function _ServiceLogs({ serviceId, serviceName }: ServiceLogsProps) {
const [isStreaming, setIsStreaming] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const logContainerRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const { data: initialLogs, isLoading } = useQuery({
queryKey: ['logs', serviceId],
queryFn: async () => {
const response = await logsApi.getServiceLogs(serviceId, { lines: 100 });
return response.logs.map((log) => ({
timestamp: log.timestamp,
message: log.message,
stream: log.stream as 'stdout' | 'stderr' | 'system',
}));
},
});
useEffect(() => {
if (initialLogs) {
setLogs(initialLogs);
}
}, [initialLogs]);
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, []);
const startStreaming = () => {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
const _token = localStorage.getItem('auth_token');
const url = new URL(`${API_BASE_URL}/api/v1/services/${serviceId}/logs`);
url.searchParams.append('follow', 'true');
const eventSource = new EventSource(url.toString(), {
withCredentials: true,
});
eventSource.onmessage = (event) => {
try {
const log: LogEntry = JSON.parse(event.data);
setLogs((prev) => [...prev.slice(-500), log]);
} catch (e) {
console.error('Failed to parse log:', e);
}
};
eventSource.onerror = () => {
console.error('EventSource error');
eventSource.close();
setIsStreaming(false);
};
eventSourceRef.current = eventSource;
setIsStreaming(true);
};
const stopStreaming = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setIsStreaming(false);
};
const clearLogs = () => {
setLogs([]);
};
const downloadLogs = () => {
const content = logs
.map((log) => `[${log.timestamp}] ${log.message}`)
.join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${serviceName}-${new Date().toISOString()}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleScroll = () => {
if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
setAutoScroll(isAtBottom);
}
};
if (isLoading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Terminal className="w-5 h-5" />
Service Logs
</CardTitle>
<div className="flex items-center gap-2">
{isStreaming ? (
<Button variant="outline" size="sm" onClick={stopStreaming}>
<Pause className="w-4 h-4 mr-1" />
Stop
</Button>
) : (
<Button variant="outline" size="sm" onClick={startStreaming}>
<Play className="w-4 h-4 mr-1" />
Stream
</Button>
)}
<Button variant="outline" size="sm" onClick={downloadLogs}>
<Download className="w-4 h-4 mr-1" />
Download
</Button>
<Button variant="outline" size="sm" onClick={clearLogs}>
<Trash2 className="w-4 h-4 mr-1" />
Clear
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div
ref={logContainerRef}
onScroll={handleScroll}
className="bg-gray-950 text-gray-100 rounded-md p-4 h-96 overflow-auto font-mono text-sm"
>
{logs.length === 0 ? (
<div className="text-gray-500 text-center py-8">
No logs available. Start the service or enable streaming to see logs.
</div>
) : (
logs.map((log, index) => (
<div
key={index}
className={`py-0.5 ${
log.stream === 'stderr'
? 'text-red-400'
: log.stream === 'system'
? 'text-yellow-400'
: 'text-gray-300'
}`}
>
<span className="text-gray-600 mr-2">
[{new Date(log.timestamp).toLocaleTimeString()}]
</span>
{log.message}
</div>
))
)}
</div>
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>
{logs.length} log entries
{autoScroll && ' • Auto-scroll enabled'}
</span>
{isStreaming && (
<span className="flex items-center gap-1 text-green-500">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Streaming...
</span>
)}
</div>
</CardContent>
</Card>
);
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import { Position } from '@xyflow/react';
interface AnimatedEdgeProps {
id: string;
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
targetPosition: Position;
sourcePosition: Position;
style?: React.CSSProperties;
markerEnd?: string;
}
export default function AnimatedEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, markerEnd, }: AnimatedEdgeProps): import("react/jsx-runtime").JSX.Element;
export {};
+25
View File
@@ -0,0 +1,25 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import React from 'react';
import { BaseEdge, EdgeLabelRenderer, getBezierPath, Position, } from '@xyflow/react';
export default function AnimatedEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, markerEnd, }) {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { id: id, path: edgePath, markerEnd: markerEnd, style: {
...style,
strokeWidth: 2,
stroke: '#94a3b8',
} }), _jsx("defs", { children: _jsxs("linearGradient", { id: `gradient-${id}`, x1: "0%", y1: "0%", x2: "100%", y2: "0%", children: [_jsx("stop", { offset: "0%", stopColor: "#3b82f6", stopOpacity: "0" }), _jsx("stop", { offset: "50%", stopColor: "#3b82f6", stopOpacity: "1" }), _jsx("stop", { offset: "100%", stopColor: "#3b82f6", stopOpacity: "0" })] }) }), _jsx("path", { d: edgePath, fill: "none", stroke: `url(#gradient-${id})`, strokeWidth: 2, strokeLinecap: "round", className: "animate-network-flow-egress", style: {
strokeDasharray: '10 5',
animation: 'networkFlowEgress 2s linear infinite',
} }), _jsx(EdgeLabelRenderer, { children: _jsx("div", { style: {
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}, className: "nodrag nopan", children: _jsx("div", { className: "bg-white dark:bg-slate-800 px-2 py-1 rounded text-xs text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700 shadow-sm", children: "network" }) }) })] }));
}
+7
View File
@@ -0,0 +1,7 @@
interface DeploymentTriggersProps {
repositoryId: string;
repositoryName: string;
projectId: string;
}
export default function DeploymentTriggers({ repositoryId, repositoryName, projectId }: DeploymentTriggersProps): import("react/jsx-runtime").JSX.Element;
export {};
File diff suppressed because one or more lines are too long
@@ -1,6 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { gitApi } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';

Some files were not shown because too many files have changed in this diff Show More