mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-03 20:12:58 +00:00
fix
This commit is contained in:
Submodule
+1
Submodule .claude/skills/desloppify added at c59f2a07ed
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 $$;
|
||||||
|
|||||||
@@ -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 $$
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 $$;
|
||||||
Generated
+1522
-1
File diff suppressed because it is too large
Load Diff
+19
-2
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare function App(): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export default App;
|
||||||
+41
@@ -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;
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 {};
|
||||||
@@ -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))) })] })] }));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+6
@@ -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 {};
|
||||||
@@ -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" })] })] }) })] }) }) }));
|
||||||
|
}
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export default function Layout(): import("react/jsx-runtime").JSX.Element;
|
||||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -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))) })] }))] }) }));
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -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" })] })] })] }));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -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..."] }))] })] })] }));
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {};
|
||||||
@@ -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
@@ -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
Reference in New Issue
Block a user