mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 22:24:42 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d5a65357 | |||
| 8f32765ffe | |||
| 21b87d3d56 |
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
"clicd/internal/lxc"
|
"clicd/internal/lxc"
|
||||||
|
"clicd/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lxcManager = lxc.NewManager()
|
var lxcManager = lxc.NewManager()
|
||||||
@@ -451,3 +452,14 @@ func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr
|
|||||||
}
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleVersion returns the current CLICD version.
|
||||||
|
func HandleVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||||
|
"version": version.Current(),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|||||||
+142
-21
@@ -1,6 +1,7 @@
|
|||||||
package lxc
|
package lxc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -105,19 +106,23 @@ func (m *Manager) updateAllRates() {
|
|||||||
}
|
}
|
||||||
lxcName := c.LxcName()
|
lxcName := c.LxcName()
|
||||||
|
|
||||||
// Read raw bytes
|
// Cache init PID once per scan so getContainerNetworkBytes / getContainerDiskIOBytes
|
||||||
memUsage := readIntCommand(fmt.Sprintf(
|
// don't each fork lxc-info separately.
|
||||||
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+
|
initPID := m.getContainerInitPID(lxcName)
|
||||||
"cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+
|
|
||||||
"cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(lxcName)))
|
|
||||||
|
|
||||||
cpuUsec := uint64(readIntCommand(fmt.Sprintf(
|
// Read memory from cgroup directly (no shell fork)
|
||||||
"(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+
|
memUsage := readCgroupFile(lxcName,
|
||||||
"cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+
|
"/sys/fs/cgroup/lxc/%s/memory.current",
|
||||||
"awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName))))
|
"/sys/fs/cgroup/lxc.payload.%s/memory.current",
|
||||||
|
"/sys/fs/cgroup/memory/lxc/%s/memory.usage_in_bytes")
|
||||||
|
|
||||||
rxBytes, txBytes := m.getContainerNetworkBytes(lxcName)
|
// Read cpu usage from cgroup directly (no shell | awk fork)
|
||||||
readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName)
|
cpuUsec := readCgroupCPUUsec(lxcName,
|
||||||
|
"/sys/fs/cgroup/lxc/%s/cpu.stat",
|
||||||
|
"/sys/fs/cgroup/lxc.payload.%s/cpu.stat")
|
||||||
|
|
||||||
|
rxBytes, txBytes := getNetworkBytesForPID(initPID)
|
||||||
|
readBytes, writeBytes := getDiskIOBytesForPID(initPID)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sample := containerUsageSample{
|
sample := containerUsageSample{
|
||||||
@@ -2245,10 +2250,38 @@ func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) {
|
|||||||
if pid == "" {
|
if pid == "" {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
dir := fmt.Sprintf("/proc/%s/net", pid)
|
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
|
||||||
rx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print rx}' || echo 0", shellQuote(dir)))
|
}
|
||||||
tx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print tx}' || echo 0", shellQuote(dir)))
|
|
||||||
return uint64(rx), uint64(tx)
|
// readProcNetDev parses /proc/PID/net/dev directly (no shell/awk fork).
|
||||||
|
func readProcNetDev(path string) (uint64, uint64) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
var rx, tx uint64
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
// Skip header lines
|
||||||
|
if strings.Contains(line, "|") || strings.Contains(line, "face") || strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
// Fields: face | rx_bytes rx_packets rx_errs rx_drop rx_fifo rx_frame rx_compressed rx_multicast | tx_bytes tx_packets tx_errs tx_drop tx_fifo tx_colls tx_carrier tx_compressed
|
||||||
|
// Skip loopback (face starts with "lo")
|
||||||
|
if len(fields) < 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(fields[0], "lo") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
t, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||||
|
rx += r
|
||||||
|
tx += t
|
||||||
|
}
|
||||||
|
return rx, tx
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
|
func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
|
||||||
@@ -2256,10 +2289,22 @@ func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
|
|||||||
if pid == "" {
|
if pid == "" {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
// /proc/PID/io format: "field_name: value" per line
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
|
||||||
// Fields: rchar, wchar, syscr, syscw, read_bytes, write_bytes, cancelled_write_bytes
|
if err != nil {
|
||||||
readBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^read_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
|
return 0, 0
|
||||||
writeBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^write_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
|
}
|
||||||
|
var readBytes, writeBytes uint64
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "read_bytes:") {
|
||||||
|
val := strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:"))
|
||||||
|
readBytes, _ = strconv.ParseUint(val, 10, 64)
|
||||||
|
} else if strings.HasPrefix(line, "write_bytes:") {
|
||||||
|
val := strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:"))
|
||||||
|
writeBytes, _ = strconv.ParseUint(val, 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
return readBytes, writeBytes
|
return readBytes, writeBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2272,6 +2317,73 @@ func (m *Manager) getContainerInitPID(lxcName string) string {
|
|||||||
return strings.TrimSpace(string(out))
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readCgroupFile tries each path template in order, reads the file directly (no shell),
|
||||||
|
// and returns the first valid int64 value.
|
||||||
|
func readCgroupFile(name string, paths ...string) int64 {
|
||||||
|
for _, tmpl := range paths {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||||
|
if err == nil && val > 0 {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCgroupCPUUsec tries each path template, reads cpu.stat, and extracts usage_usec.
|
||||||
|
func readCgroupCPUUsec(name string, paths ...string) uint64 {
|
||||||
|
for _, tmpl := range paths {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "usage_usec ") {
|
||||||
|
val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "usage_usec")), 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNetworkBytesForPID reads /proc/PID/net/dev for a given PID (no lxc-info needed).
|
||||||
|
func getNetworkBytesForPID(pid string) (uint64, uint64) {
|
||||||
|
if pid == "" {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDiskIOBytesForPID reads /proc/PID/io for a given PID (no lxc-info needed).
|
||||||
|
func getDiskIOBytesForPID(pid string) (uint64, uint64) {
|
||||||
|
if pid == "" {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
var readBytes, writeBytes uint64
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "read_bytes:") {
|
||||||
|
readBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:")), 10, 64)
|
||||||
|
} else if strings.HasPrefix(line, "write_bytes:") {
|
||||||
|
writeBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")), 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return readBytes, writeBytes
|
||||||
|
}
|
||||||
|
|
||||||
// getContainerUptimeSeconds returns how long the container has been running (in seconds).
|
// getContainerUptimeSeconds returns how long the container has been running (in seconds).
|
||||||
func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 {
|
func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 {
|
||||||
pid := m.getContainerInitPID(lxcName)
|
pid := m.getContainerInitPID(lxcName)
|
||||||
@@ -2399,6 +2511,7 @@ func (m *Manager) AccumulateTraffic() {
|
|||||||
lastTrafficSnapshotMu.Lock()
|
lastTrafficSnapshotMu.Lock()
|
||||||
defer lastTrafficSnapshotMu.Unlock()
|
defer lastTrafficSnapshotMu.Unlock()
|
||||||
|
|
||||||
|
changed := false
|
||||||
for i := range config.AppConfig.Containers {
|
for i := range config.AppConfig.Containers {
|
||||||
c := &config.AppConfig.Containers[i]
|
c := &config.AppConfig.Containers[i]
|
||||||
if c.Status != "running" {
|
if c.Status != "running" {
|
||||||
@@ -2412,18 +2525,26 @@ func (m *Manager) AccumulateTraffic() {
|
|||||||
c.TrafficUsedTX = 0
|
c.TrafficUsedTX = 0
|
||||||
c.TrafficResetDate = currentMonth
|
c.TrafficResetDate = currentMonth
|
||||||
delete(lastTrafficSnapshot, c.LxcName())
|
delete(lastTrafficSnapshot, c.LxcName())
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
rx, tx := m.getContainerNetworkBytes(c.LxcName())
|
rx, tx := m.getContainerNetworkBytes(c.LxcName())
|
||||||
prev, exists := lastTrafficSnapshot[c.LxcName()]
|
prev, exists := lastTrafficSnapshot[c.LxcName()]
|
||||||
// Only add the DELTA (increment since last snapshot)
|
// Only add the DELTA (increment since last snapshot)
|
||||||
if exists && rx >= prev.RXBytes && tx >= prev.TXBytes {
|
if exists && rx >= prev.RXBytes && tx >= prev.TXBytes {
|
||||||
c.TrafficUsedRX += int64(rx - prev.RXBytes)
|
deltaRX := int64(rx - prev.RXBytes)
|
||||||
c.TrafficUsedTX += int64(tx - prev.TXBytes)
|
deltaTX := int64(tx - prev.TXBytes)
|
||||||
|
if deltaRX > 0 || deltaTX > 0 {
|
||||||
|
c.TrafficUsedRX += deltaRX
|
||||||
|
c.TrafficUsedTX += deltaTX
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx}
|
lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx}
|
||||||
}
|
}
|
||||||
|
if changed {
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetTrafficInfo returns traffic usage info for a container
|
// GetTrafficInfo returns traffic usage info for a container
|
||||||
func (m *Manager) GetTrafficInfo(id int) map[string]interface{} {
|
func (m *Manager) GetTrafficInfo(id int) map[string]interface{} {
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"clicd/internal/api"
|
"clicd/internal/api"
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
"clicd/internal/lxc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// webFS holds embedded frontend files
|
// webFS holds embedded frontend files
|
||||||
@@ -112,6 +110,9 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
||||||
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
||||||
|
|
||||||
|
// Version (public)
|
||||||
|
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
if webFS != nil {
|
if webFS != nil {
|
||||||
fs := http.FileServer(webFS)
|
fs := http.FileServer(webFS)
|
||||||
@@ -146,7 +147,6 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
func Run() error {
|
func Run() error {
|
||||||
// Use embedded frontend files
|
// Use embedded frontend files
|
||||||
webFS = GetEmbeddedFS()
|
webFS = GetEmbeddedFS()
|
||||||
startExpiryMonitor()
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
setupRoutes(mux)
|
setupRoutes(mux)
|
||||||
@@ -163,15 +163,3 @@ func Run() error {
|
|||||||
return server.ListenAndServe()
|
return server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startExpiryMonitor() {
|
|
||||||
manager := lxc.NewManager()
|
|
||||||
go func() {
|
|
||||||
manager.StopExpiredContainers(time.Now())
|
|
||||||
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for now := range ticker.C {
|
|
||||||
manager.StopExpiredContainers(now)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.0.6"
|
Version = "1.0.8"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,3 +12,5 @@ func Current() string {
|
|||||||
return Version
|
return Version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -5,8 +5,16 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CLICD - LXC Container Manager</title>
|
<title>CLICD - LXC Container Manager</title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var theme = localStorage.getItem('clicd_theme');
|
||||||
|
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-white text-black">
|
<body class="bg-white text-black dark:bg-gray-950 dark:text-white">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-gray-950">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black dark:border-white"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function Layout() {
|
|||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex">
|
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
|
||||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||||
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
|
||||||
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
|
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
|
||||||
|
|
||||||
@@ -45,17 +46,19 @@ export default function ResourceStatsPanel({
|
|||||||
charts: ResourceChartConfig[]
|
charts: ResourceChartConfig[]
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden">
|
<section className="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 bg-white">
|
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||||
<h2 className="text-sm font-semibold text-gray-950">统计信息</h2>
|
<h2 className="text-sm font-semibold text-gray-950 dark:text-white">统计信息</h2>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="inline-flex rounded border border-gray-200 bg-gray-50 p-0.5">
|
<div className="inline-flex rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-0.5">
|
||||||
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
|
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
onClick={() => onRangeChange(item)}
|
onClick={() => onRangeChange(item)}
|
||||||
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
|
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
|
||||||
range === item ? 'bg-gray-800 text-white shadow-sm' : 'text-gray-500 hover:text-gray-900'
|
range === item
|
||||||
|
? 'bg-gray-800 text-white shadow-sm dark:bg-white dark:text-black'
|
||||||
|
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{rangeLabels[item]}
|
{rangeLabels[item]}
|
||||||
@@ -64,7 +67,7 @@ export default function ResourceStatsPanel({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-900"
|
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white"
|
||||||
title="刷新"
|
title="刷新"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
@@ -90,11 +93,11 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
|
|||||||
<div className={`p-4 ${className}`}>
|
<div className={`p-4 ${className}`}>
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950">
|
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
<span className="text-gray-500">{chart.icon}</span>
|
<span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
|
||||||
<span>{chart.title}</span>
|
<span>{chart.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400">{chart.detail}</p>}
|
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3 text-right">
|
<div className="grid grid-cols-3 gap-3 text-right">
|
||||||
<Stat label="当前" value={chart.formatValue(chart.current)} />
|
<Stat label="当前" value={chart.formatValue(chart.current)} />
|
||||||
@@ -115,8 +118,8 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
|
|||||||
function Stat({ label, value }: { label: string; value: string }) {
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-gray-400">{label}</div>
|
<div className="text-[10px] text-gray-400 dark:text-gray-500">{label}</div>
|
||||||
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div>
|
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -132,6 +135,9 @@ function LineAreaChart({
|
|||||||
formatValue: (value: number) => string
|
formatValue: (value: number) => string
|
||||||
unitLabel?: string
|
unitLabel?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
const width = 520
|
const width = 520
|
||||||
const height = 150
|
const height = 150
|
||||||
const left = 50
|
const left = 50
|
||||||
@@ -158,12 +164,20 @@ function LineAreaChart({
|
|||||||
const yTicks = [1, 0.5, 0]
|
const yTicks = [1, 0.5, 0]
|
||||||
const xTicks = [0, 0.5, 1]
|
const xTicks = [0, 0.5, 1]
|
||||||
|
|
||||||
|
// Dark mode colors
|
||||||
|
const gridStroke = isDark ? '#374151' : '#e5e7eb'
|
||||||
|
const gridStrokeV = isDark ? '#1f2937' : '#edf0f2'
|
||||||
|
const axisStroke = isDark ? '#9ca3af' : '#888'
|
||||||
|
const lineStroke = isDark ? '#f9fafb' : '#444'
|
||||||
|
const gradientTop = isDark ? '#f9fafb' : '#555'
|
||||||
|
const gradientBottom = isDark ? '#374151' : '#555'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#555" stopOpacity="0.25" />
|
<stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
|
||||||
<stop offset="100%" stopColor="#555" stopOpacity="0.02" />
|
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -171,8 +185,8 @@ function LineAreaChart({
|
|||||||
const y = top + (1 - tick) * innerHeight
|
const y = top + (1 - tick) * innerHeight
|
||||||
return (
|
return (
|
||||||
<g key={tick}>
|
<g key={tick}>
|
||||||
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" />
|
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke={gridStroke} strokeDasharray="3 3" />
|
||||||
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill="#888">
|
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill={axisStroke}>
|
||||||
{formatValue(maxValue * tick)}
|
{formatValue(maxValue * tick)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -184,8 +198,8 @@ function LineAreaChart({
|
|||||||
const ts = minTs + tick * span
|
const ts = minTs + tick * span
|
||||||
return (
|
return (
|
||||||
<g key={tick}>
|
<g key={tick}>
|
||||||
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" />
|
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke={gridStrokeV} strokeDasharray="3 3" />
|
||||||
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill="#888">
|
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill={axisStroke}>
|
||||||
{formatTime(ts)}
|
{formatTime(ts)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -193,15 +207,15 @@ function LineAreaChart({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{unitLabel && (
|
{unitLabel && (
|
||||||
<text x={left - 45} y={top + 10} fontSize="10" fill="#888">
|
<text x={left - 45} y={top + 10} fontSize="10" fill={axisStroke}>
|
||||||
{unitLabel}
|
{unitLabel}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke="#888" />
|
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
|
||||||
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke="#888" />
|
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
|
||||||
<polygon points={area} fill="url(#resource-chart-fill)" />
|
<polygon points={area} fill="url(#resource-chart-fill)" />
|
||||||
<polyline points={line} fill="none" stroke="#444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
|
||||||
interface RingStatProps {
|
interface RingStatProps {
|
||||||
value: number
|
value: number
|
||||||
@@ -10,11 +11,17 @@ interface RingStatProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
|
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
const radius = (size - strokeWidth) / 2
|
const radius = (size - strokeWidth) / 2
|
||||||
const circumference = radius * 2 * Math.PI
|
const circumference = radius * 2 * Math.PI
|
||||||
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
|
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
|
||||||
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
||||||
|
|
||||||
|
const bgStroke = isDark ? '#374151' : '#f3f4f6'
|
||||||
|
const progressStroke = isDark ? '#f9fafb' : '#000000'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="relative" style={{ width: size, height: size }}>
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
@@ -25,7 +32,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
|||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
r={radius}
|
r={radius}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#f3f4f6"
|
stroke={bgStroke}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
/>
|
/>
|
||||||
{/* Progress ring */}
|
{/* Progress ring */}
|
||||||
@@ -34,7 +41,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
|||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
r={radius}
|
r={radius}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#000000"
|
stroke={progressStroke}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray={circumference}
|
strokeDasharray={circumference}
|
||||||
@@ -44,12 +51,12 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
|||||||
</svg>
|
</svg>
|
||||||
{/* Center value */}
|
{/* Center value */}
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
<span className="text-2xl font-bold text-black">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
|
<span className="text-2xl font-bold text-black dark:text-white">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-center">
|
<div className="mt-2 text-center">
|
||||||
<div className="text-sm font-medium text-gray-800">{label}</div>
|
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">{label}</div>
|
||||||
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>}
|
{subLabel && <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{subLabel}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -96,8 +103,8 @@ export default function RingStats({
|
|||||||
const hasSwap = swapTotal > 0
|
const hasSwap = swapTotal > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-5">
|
||||||
<h2 className="text-sm font-semibold text-black mb-4">状态</h2>
|
<h2 className="text-sm font-semibold text-black dark:text-white mb-4">状态</h2>
|
||||||
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
|
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
|
||||||
<RingStat
|
<RingStat
|
||||||
value={cpuPercent}
|
value={cpuPercent}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -6,15 +7,19 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Moon,
|
||||||
Package,
|
Package,
|
||||||
Route,
|
Route,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
Sun,
|
||||||
UserCog,
|
UserCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
import { getVersion } from '../services/api'
|
||||||
import AppIcon from './AppIcon'
|
import AppIcon from './AppIcon'
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -26,6 +31,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { logout, isSubUser } = useAuth()
|
const { logout, isSubUser } = useAuth()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const [version, setVersion] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getVersion()
|
||||||
|
.then(res => {
|
||||||
|
if (res.data?.data?.version) {
|
||||||
|
setVersion(res.data.data.version)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const isContainerPage =
|
const isContainerPage =
|
||||||
location.pathname.startsWith('/containers') ||
|
location.pathname.startsWith('/containers') ||
|
||||||
@@ -42,27 +59,27 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 ${
|
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 dark:bg-gray-900 dark:border-gray-700 ${
|
||||||
collapsed ? 'w-16' : 'w-60'
|
collapsed ? 'w-16' : 'w-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200">
|
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center">
|
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800">
|
||||||
<AppIcon className="w-5 h-5" />
|
<AppIcon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-black text-sm">CLICD</span>
|
<span className="font-bold text-black text-sm dark:text-white">CLICD</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{collapsed && (
|
{collapsed && (
|
||||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto">
|
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800">
|
||||||
<AppIcon className="w-5 h-5" />
|
<AppIcon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="p-1 rounded hover:bg-gray-100 text-gray-500"
|
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
|
||||||
title="切换侧边栏"
|
title="切换侧边栏"
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
@@ -79,8 +96,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
location.pathname === '/'
|
location.pathname === '/'
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
<LayoutDashboard className="w-4 h-4" />
|
||||||
@@ -92,8 +109,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/containers')}
|
onClick={() => navigate('/containers')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isContainerPage
|
isContainerPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Server className="w-4 h-4" />
|
<Server className="w-4 h-4" />
|
||||||
@@ -105,8 +122,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/images')}
|
onClick={() => navigate('/images')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isImagesPage
|
isImagesPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Package className="w-4 h-4" />
|
<Package className="w-4 h-4" />
|
||||||
@@ -120,8 +137,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/oversell')}
|
onClick={() => navigate('/oversell')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isOversellPage
|
isOversellPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4" />
|
<Settings2 className="w-4 h-4" />
|
||||||
@@ -132,8 +149,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/security')}
|
onClick={() => navigate('/security')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isSecurityPage
|
isSecurityPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShieldAlert className="w-4 h-4" />
|
<ShieldAlert className="w-4 h-4" />
|
||||||
@@ -144,8 +161,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/snapshots')}
|
onClick={() => navigate('/snapshots')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isSnapshotsPage
|
isSnapshotsPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Camera className="w-4 h-4" />
|
<Camera className="w-4 h-4" />
|
||||||
@@ -156,8 +173,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/routing')}
|
onClick={() => navigate('/routing')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isRoutingPage
|
isRoutingPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Route className="w-4 h-4" />
|
<Route className="w-4 h-4" />
|
||||||
@@ -168,8 +185,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/audit-logs')}
|
onClick={() => navigate('/audit-logs')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isAuditLogsPage
|
isAuditLogsPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ScrollText className="w-4 h-4" />
|
<ScrollText className="w-4 h-4" />
|
||||||
@@ -180,8 +197,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/api-integration')}
|
onClick={() => navigate('/api-integration')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isApiIntegrationPage
|
isApiIntegrationPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Code2 className="w-4 h-4" />
|
<Code2 className="w-4 h-4" />
|
||||||
@@ -192,8 +209,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/settings')}
|
onClick={() => navigate('/settings')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isSettingsPage
|
isSettingsPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<UserCog className="w-4 h-4" />
|
<UserCog className="w-4 h-4" />
|
||||||
@@ -203,10 +220,36 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 p-2">
|
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
|
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<Sun className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
{version && (
|
||||||
|
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
|
||||||
|
{collapsed ? (
|
||||||
|
<span title={`v${version}`}>v{version.split('.').slice(0, 2).join('.')}</span>
|
||||||
|
) : (
|
||||||
|
<span>v{version}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors"
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
{!collapsed && <span>退出登录</span>}
|
{!collapsed && <span>退出登录</span>}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme
|
||||||
|
toggleTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType>({ theme: 'light', toggleTheme: () => {} })
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
const stored = localStorage.getItem('clicd_theme') as Theme | null
|
||||||
|
if (stored === 'dark' || stored === 'light') return stored
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (theme === 'dark') {
|
||||||
|
root.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark')
|
||||||
|
}
|
||||||
|
localStorage.setItem('clicd_theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext)
|
||||||
|
}
|
||||||
+131
-3
@@ -14,19 +14,147 @@ body {
|
|||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: #f1f1f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #888;
|
background: #888;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #555;
|
background: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ DARK MODE OVERRIDES ============ */
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
background-color: #030712;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background overrides */
|
||||||
|
.dark .bg-white { background-color: #111827 !important; }
|
||||||
|
.dark .bg-gray-50 { background-color: #030712 !important; }
|
||||||
|
.dark .bg-gray-100 { background-color: #1f2937 !important; }
|
||||||
|
.dark .bg-gray-200 { background-color: #374151 !important; }
|
||||||
|
|
||||||
|
/* Text overrides */
|
||||||
|
.dark .text-black { color: #f9fafb !important; }
|
||||||
|
.dark .text-gray-950 { color: #f9fafb !important; }
|
||||||
|
.dark .text-gray-900 { color: #f3f4f6 !important; }
|
||||||
|
.dark .text-gray-800 { color: #e5e7eb !important; }
|
||||||
|
.dark .text-gray-700 { color: #d1d5db !important; }
|
||||||
|
.dark .text-gray-600 { color: #9ca3af !important; }
|
||||||
|
.dark .text-gray-500 { color: #9ca3af !important; }
|
||||||
|
.dark .text-gray-400 { color: #6b7280 !important; }
|
||||||
|
|
||||||
|
/* Border overrides */
|
||||||
|
.dark .border-gray-100 { border-color: #1f2937 !important; }
|
||||||
|
.dark .border-gray-200 { border-color: #374151 !important; }
|
||||||
|
.dark .border-gray-300 { border-color: #4b5563 !important; }
|
||||||
|
|
||||||
|
/* Divider overrides */
|
||||||
|
.dark .divide-gray-50 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
|
||||||
|
.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
|
||||||
|
|
||||||
|
/* Hover background overrides */
|
||||||
|
.dark .hover\:bg-gray-50:hover { background-color: #1f2937 !important; }
|
||||||
|
.dark .hover\:bg-gray-100:hover { background-color: #1f2937 !important; }
|
||||||
|
.dark .hover\:bg-gray-200:hover { background-color: #374151 !important; }
|
||||||
|
|
||||||
|
/* Hover text overrides */
|
||||||
|
.dark .hover\:text-black:hover { color: #f9fafb !important; }
|
||||||
|
.dark .hover\:text-gray-900:hover { color: #f3f4f6 !important; }
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
|
||||||
|
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
|
||||||
|
|
||||||
|
/* bg-black buttons in dark mode -> light */
|
||||||
|
.dark .bg-black { background-color: #f9fafb !important; }
|
||||||
|
.dark .bg-black + span,
|
||||||
|
.dark button.bg-black { color: #111827 !important; }
|
||||||
|
.dark button.bg-black span { color: #111827 !important; }
|
||||||
|
|
||||||
|
/* Fix for CTA buttons (bg-black text-white) */
|
||||||
|
.dark button.bg-black,
|
||||||
|
.dark a.bg-black {
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
color: #111827 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix nested text-white inside bg-black in dark mode */
|
||||||
|
.dark .bg-black .text-white,
|
||||||
|
.dark .bg-black.text-white {
|
||||||
|
color: #111827 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invert sidebar active state */
|
||||||
|
.dark button.bg-black.text-white,
|
||||||
|
.dark button.bg-black > span {
|
||||||
|
color: #111827 !important;
|
||||||
|
}
|
||||||
|
.dark button.bg-black svg {
|
||||||
|
color: #111827 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover: bg-gray-800 in dark mode */
|
||||||
|
.dark .hover\:bg-gray-800:hover { background-color: #e5e7eb !important; color: #111827 !important; }
|
||||||
|
|
||||||
|
/* Status badge backgrounds */
|
||||||
|
.dark .bg-green-50 { background-color: #064e3b !important; }
|
||||||
|
.dark .bg-red-50 { background-color: #450a0a !important; }
|
||||||
|
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
||||||
|
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
||||||
|
.dark .bg-amber-100 { background-color: #78350f !important; }
|
||||||
|
|
||||||
|
/* Status badge text */
|
||||||
|
.dark .text-green-700 { color: #6ee7b7 !important; }
|
||||||
|
.dark .text-red-600 { color: #fca5a5 !important; }
|
||||||
|
.dark .text-red-700 { color: #fca5a5 !important; }
|
||||||
|
.dark .text-amber-600 { color: #fcd34d !important; }
|
||||||
|
.dark .text-amber-700 { color: #fcd34d !important; }
|
||||||
|
.dark .text-emerald-700 { color: #6ee7b7 !important; }
|
||||||
|
|
||||||
|
/* Focus ring */
|
||||||
|
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
||||||
|
.dark .focus\:border-black:focus { border-color: #f9fafb !important; }
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
.dark .accent-black { accent-color: #f9fafb !important; }
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.dark .border-black { border-color: #f9fafb !important; }
|
||||||
|
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
||||||
|
.dark .border-t-black { border-top-color: #f9fafb !important; }
|
||||||
|
.dark .animate-spin.rounded-full { border-color: #f9fafb !important; border-bottom-color: transparent !important; }
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.dark .placeholder-gray-400::placeholder { color: #6b7280 !important; }
|
||||||
|
|
||||||
|
/* Success/Error text standalone */
|
||||||
|
.dark .text-green-600 { color: #6ee7b7 !important; }
|
||||||
|
|
||||||
|
/* Modal backdrop */
|
||||||
|
.dark .bg-black\/50 { background-color: rgba(0,0,0,0.7) !important; }
|
||||||
|
|
||||||
|
/* Toggle / switch */
|
||||||
|
.dark .bg-gray-300 { background-color: #4b5563 !important; }
|
||||||
|
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
|
||||||
|
.dark .peer-checked\:bg-black:checked + *,
|
||||||
|
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
import { DialogProvider } from './components/Dialog'
|
import { DialogProvider } from './components/Dialog'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<App />
|
<App />
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -553,4 +553,8 @@ export const getSecuritySummary = () =>
|
|||||||
export const createWebSSHTicket = (containerName: string) =>
|
export const createWebSSHTicket = (containerName: string) =>
|
||||||
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
|
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
|
||||||
|
|
||||||
|
// Version
|
||||||
|
export const getVersion = () =>
|
||||||
|
api.get<APIResponse<{ version: string }>>('/version')
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
|||||||
Reference in New Issue
Block a user