diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c3313f1..35e019f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 2.41.0 +- The home page is now an operator dashboard with the nginx status, resource counts, expiring certificates, recent error + logs, and a traffic summary. +- The feature tour with help videos moved to a dedicated Help page, accessible from the question-mark icon in the + header menu. - Development workflow improvements - Fixed double scrollbar on the logs page after the UI overhaul diff --git a/Dockerfile b/Dockerfile index c91f66fbd..adcf3f858 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,7 +62,8 @@ RUN apk add --no-cache \ zlib \ openssl \ ca-certificates \ - curl && \ + curl \ + procps && \ update-ca-certificates ARG TARGETPLATFORM diff --git a/api/nginx/status_handler.go b/api/nginx/status_handler.go index e4da8a09d..e3e89fd57 100644 --- a/api/nginx/status_handler.go +++ b/api/nginx/status_handler.go @@ -13,6 +13,9 @@ type statusHandler struct { } func (h statusHandler) handle(ctx *gin.Context) { - running := h.commands.GetStatus(ctx.Request.Context()) - ctx.JSON(http.StatusOK, gin.H{"running": running}) + status := h.commands.GetStatus(ctx.Request.Context()) + ctx.JSON(http.StatusOK, gin.H{ + "running": status.Running, + "uptimeSeconds": status.UptimeSeconds, + }) } diff --git a/api/nginx/status_handler_test.go b/api/nginx/status_handler_test.go index bb9af51bb..1d249a81d 100644 --- a/api/nginx/status_handler_test.go +++ b/api/nginx/status_handler_test.go @@ -23,10 +23,11 @@ func Test_statusHandler(t *testing.T) { controller := gomock.NewController(t) defer controller.Finish() + uptime := int64(3600) commands := nginx.NewMockedCommands(controller) commands.EXPECT(). GetStatus(gomock.Any()). - Return(true) + Return(nginx.Status{Running: true, UptimeSeconds: &uptime}) handler := statusHandler{ commands: commands, @@ -39,9 +40,10 @@ func Test_statusHandler(t *testing.T) { engine.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) - var response map[string]bool + var response map[string]any json.Unmarshal(recorder.Body.Bytes(), &response) - assert.True(t, response["running"]) + assert.True(t, response["running"].(bool)) + assert.Equal(t, float64(3600), response["uptimeSeconds"].(float64)) }) }) } diff --git a/core/nginx/commands.go b/core/nginx/commands.go index dd7644ce9..dc0ba2bc2 100644 --- a/core/nginx/commands.go +++ b/core/nginx/commands.go @@ -30,7 +30,7 @@ type Commands interface { search *LogSearch, ) ([]logline.LogLine, error) GetMainLogs(ctx context.Context, lines int, search *LogSearch) ([]logline.LogLine, error) - GetStatus(ctx context.Context) bool + GetStatus(ctx context.Context) Status GetTrafficStats(ctx context.Context) (*Stats, error) GetConfigFiles(ctx context.Context, input GetConfigFilesInput) ([]byte, error) GetMetadata(ctx context.Context) (*Metadata, error) diff --git a/core/nginx/model.go b/core/nginx/model.go index dc74f02f3..e2aa3c561 100644 --- a/core/nginx/model.go +++ b/core/nginx/model.go @@ -8,6 +8,11 @@ const ( NoneSupportType SupportType = "NONE" ) +type Status struct { + UptimeSeconds *int64 + Running bool +} + type Stats struct { ServerZones map[string]StatsZoneData FilterZones map[string]map[string]StatsZoneData diff --git a/core/nginx/process_manager_test.go b/core/nginx/process_manager_test.go index 3f6a34168..ef9145ad9 100644 --- a/core/nginx/process_manager_test.go +++ b/core/nginx/process_manager_test.go @@ -3,6 +3,7 @@ package nginx import ( "os" "path/filepath" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -52,4 +53,13 @@ func Test_processManager(t *testing.T) { assert.NoFileExists(t, socketFile) }) }) + + t.Run("uptimeSeconds", func(t *testing.T) { + pidFile := filepath.Join(tmpDir, "nginx.pid") + _ = os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0o644) + + uptime, err := manager.uptimeSeconds() + assert.NoError(t, err) + assert.GreaterOrEqual(t, uptime, int64(0)) + }) } diff --git a/core/nginx/process_manager_unix.go b/core/nginx/process_manager_unix.go index 17a023dbb..78e253ae5 100644 --- a/core/nginx/process_manager_unix.go +++ b/core/nginx/process_manager_unix.go @@ -3,8 +3,13 @@ package nginx import ( + "errors" "os" + "os/exec" + "strconv" + "strings" "syscall" + "time" "dillmann.com.br/nginx-ignition/core/common/log" ) @@ -27,3 +32,39 @@ func (m *processManager) start() error { log.Infof("nginx started") return nil } + +func (m *processManager) uptimeSeconds() (int64, error) { + pid, err := m.currentPid() + if err != nil { + return 0, err + } + if pid == 0 { + return 0, errors.New("nginx is not running") + } + + return processUptimeSeconds(pid) +} + +func processUptimeSeconds(pid int64) (int64, error) { + command := exec.Command("ps", "-o", "lstart=", "-p", strconv.FormatInt(pid, 10)) + output, err := command.Output() + if err != nil { + return 0, err + } + + startText := strings.TrimSpace(string(output)) + if startText == "" { + return 0, errors.New("process not found") + } + + startTime, err := time.ParseInLocation("Mon Jan _2 15:04:05 2006", startText, time.Local) + if err != nil { + return 0, err + } + + seconds := int64(time.Since(startTime).Seconds()) + if seconds < 0 { + return 0, nil + } + return seconds, nil +} diff --git a/core/nginx/process_manager_windows.go b/core/nginx/process_manager_windows.go index 11d4ee88d..c2482416f 100644 --- a/core/nginx/process_manager_windows.go +++ b/core/nginx/process_manager_windows.go @@ -7,6 +7,7 @@ import ( "errors" "os" "strings" + "syscall" "time" "dillmann.com.br/nginx-ignition/core/common/log" @@ -27,6 +28,37 @@ func (m *processManager) start() error { return nil } +func (m *processManager) uptimeSeconds() (int64, error) { + pid, err := m.currentPid() + if err != nil { + return 0, err + } + + if pid == 0 { + return 0, errors.New("nginx is not running") + } + + handle, err := syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + if err != nil { + return 0, err + } + + defer syscall.CloseHandle(handle) + var creationTime, exitTime, kernelTime, userTime syscall.Filetime + + if err := syscall.GetProcessTimes(handle, &creationTime, &exitTime, &kernelTime, &userTime); err != nil { + return 0, err + } + + startTime := time.Unix(0, creationTime.Nanoseconds()) + seconds := int64(time.Since(startTime).Seconds()) + if seconds < 0 { + return 0, nil + } + + return seconds, nil +} + func (m *processManager) runBackgroundCommand(waitDelay time.Duration, extraArgs ...string) error { cmd := m.prepareCommand(extraArgs...) diff --git a/core/nginx/service.go b/core/nginx/service.go index 806de1de0..eff49140a 100644 --- a/core/nginx/service.go +++ b/core/nginx/service.go @@ -133,8 +133,21 @@ func (s *service) Stop(ctx context.Context) error { }) } -func (s *service) GetStatus(_ context.Context) bool { - return s.semaphore.currentState() == runningState +func (s *service) GetStatus(_ context.Context) Status { + running := s.semaphore.currentState() == runningState + status := Status{Running: running} + if !running { + return status + } + + uptime, err := s.processManager.uptimeSeconds() + if err != nil { + log.Warnf("unable to resolve nginx uptime: %v", err) + return status + } + + status.UptimeSeconds = &uptime + return status } func (s *service) GetHostLogs( diff --git a/core/nginx/service_test.go b/core/nginx/service_test.go index 94293635f..2f0afe9c6 100644 --- a/core/nginx/service_test.go +++ b/core/nginx/service_test.go @@ -169,22 +169,32 @@ func Test_service(t *testing.T) { }) t.Run("GetStatus", func(t *testing.T) { - t.Run("returns true when running", func(t *testing.T) { + t.Run("returns running when running", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "nginx-status-test") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + nginxService := &service{ semaphore: &semaphore{ state: runningState, }, + processManager: &processManager{ + configPath: tmpDir, + }, } - assert.True(t, nginxService.GetStatus(t.Context())) + status := nginxService.GetStatus(t.Context()) + assert.True(t, status.Running) }) - t.Run("returns false when stopped", func(t *testing.T) { + t.Run("returns not running when stopped", func(t *testing.T) { nginxService := &service{ semaphore: &semaphore{ state: stoppedState, }, } - assert.False(t, nginxService.GetStatus(t.Context())) + status := nginxService.GetStatus(t.Context()) + assert.False(t, status.Running) + assert.Nil(t, status.UptimeSeconds) }) }) } diff --git a/frontend/src/domain/Routes.tsx b/frontend/src/domain/Routes.tsx index 78eec0179..4c635b525 100644 --- a/frontend/src/domain/Routes.tsx +++ b/frontend/src/domain/Routes.tsx @@ -1,6 +1,7 @@ import React from "react" import LoginPage from "./authentication/LoginPage" import HomePage from "./home/HomePage" +import HelpPage from "./help/HelpPage" import AppRoute from "../core/components/router/AppRoute" import OnboardingPage from "./onboarding/OnboardingPage" import { @@ -237,6 +238,12 @@ const Routes: AppRoute[] = [ icon: , }, }, + { + path: "/help", + requiresAuthentication: true, + fullPage: false, + component: , + }, { path: "/", requiresAuthentication: true, diff --git a/frontend/src/domain/certificate/CertificateService.ts b/frontend/src/domain/certificate/CertificateService.ts index 46f929783..dec22d17c 100644 --- a/frontend/src/domain/certificate/CertificateService.ts +++ b/frontend/src/domain/certificate/CertificateService.ts @@ -27,6 +27,23 @@ export default class CertificateService { return this.gateway.getPage(pageSize, pageNumber, searchTerms).then(requireSuccessPayload) } + async listAll(searchTerms?: string): Promise { + const pageSize = 100 + const certificates: CertificateResponse[] = [] + let pageNumber = 0 + + while (true) { + const page = await this.list(pageSize, pageNumber, searchTerms) + certificates.push(...page.contents) + + if (page.contents.length === 0 || certificates.length >= page.totalItems) break + + pageNumber++ + } + + return certificates + } + async delete(id: string): Promise { return this.gateway.delete(id).then(requireSuccessResponse) } diff --git a/frontend/src/domain/help/HelpPage.css b/frontend/src/domain/help/HelpPage.css new file mode 100644 index 000000000..8f7afcd2e --- /dev/null +++ b/frontend/src/domain/help/HelpPage.css @@ -0,0 +1,83 @@ +.help-guide-container h1 { + font-size: 38px; + margin: 0; + text-align: center; +} + +.help-guide-subtitle { + margin: 10px 0 0 !important; + font-size: 28px !important; + text-align: center; +} + +.help-guide-header-container { + background: var(--nginxIgnition-shellHeaderBg, var(--nginxIgnition-colorBgLayout)); + padding: 120px 40px; + margin-bottom: 50px; +} + +.help-guide-container h2 { + font-size: 28px; + margin-top: 20px; +} + +.help-guide-container p { + font-size: 16px; + margin: 10px 0; + padding: 0; +} + +.help-guide-right-side-video, +.help-guide-left-side-video { + flex-grow: 0; + flex-shrink: 0; + width: 40%; +} + +.help-guide-container video { + width: 100%; +} + +.help-guide-right-side-video { + margin: 0 0 0 40px; +} + +.help-guide-left-side-video { + margin: 0 40px 0 0; +} + +.help-guide-video-mask { + border-radius: 10px; + overflow: hidden; + height: fit-content; + line-height: 0; +} + +.help-guide-video-mask video { + margin: 0; + padding: 0; + line-height: 0 !important; +} + +.help-guide-section { + margin-bottom: 80px; + padding: 0 40px; +} + +.help-guide-section-content { + flex-grow: 1; + width: 60%; +} + +.help-guide-footer-container { + background: var(--nginxIgnition-shellHeaderBg, var(--nginxIgnition-colorBgLayout)); + padding: 60px 40px; +} + +.help-guide-footer-container h1 { + font-size: 24px; +} + +.help-guide-footer-container p { + font-size: 18px !important; +} diff --git a/frontend/src/domain/help/HelpPage.tsx b/frontend/src/domain/help/HelpPage.tsx new file mode 100644 index 000000000..90b193056 --- /dev/null +++ b/frontend/src/domain/help/HelpPage.tsx @@ -0,0 +1,225 @@ +import React from "react" +import AppShellContext from "../../core/components/shell/AppShellContext" +import "./HelpPage.css" +import { + AuditOutlined, + BlockOutlined, + FileProtectOutlined, + FileSearchOutlined, + HddOutlined, + MergeCellsOutlined, + SettingOutlined, + ApartmentOutlined, + RocketOutlined, +} from "@ant-design/icons" +import { Flex } from "antd" +import Videos from "./videos/Videos" +import { Link } from "react-router-dom" +import MessageKey from "../../core/i18n/model/MessageKey.generated" +import { I18n } from "../../core/i18n/I18n" + +export default class HelpPage extends React.PureComponent { + componentDidMount() { + AppShellContext.get().updateConfig({ + noContainerPadding: true, + }) + } + + render() { + return ( +
+
+

+ +

+

+ +

+
+ + + +

+ +

+

+ +

+

+ +

+

+ +

+
+ +
+
+
+
+ + + +
+
+
+ +

+ +

+

+ +

+

+ +

+
+
+ + + +

+ +

+

+ +

+

+ +

+

+ +

+
+ +
+
+
+
+ + + +
+
+
+ +

+ +

+

+ +

+

+ +

+
+
+ + + +

+ +

+

+ +

+
+ +
+
+
+
+ + + +
+
+
+ +

+ +

+

+ +

+
+
+ + + +

+ +

+

+ +

+
+ +
+
+
+
+ + + +
+
+
+ +

+ +

+

+ +

+

+ +

+
+
+ + + +

+ +

+

+ +

+

+ +

+

+ +

+
+ +
+
+
+
+ +
+

+ +

+

+ + + + . +

+
+
+ ) + } +} diff --git a/frontend/src/domain/home/videos/Videos.ts b/frontend/src/domain/help/videos/Videos.ts similarity index 100% rename from frontend/src/domain/home/videos/Videos.ts rename to frontend/src/domain/help/videos/Videos.ts diff --git a/frontend/src/domain/home/videos/access-lists.mp4 b/frontend/src/domain/help/videos/access-lists.mp4 similarity index 100% rename from frontend/src/domain/home/videos/access-lists.mp4 rename to frontend/src/domain/help/videos/access-lists.mp4 diff --git a/frontend/src/domain/home/videos/caches.mp4 b/frontend/src/domain/help/videos/caches.mp4 similarity index 100% rename from frontend/src/domain/home/videos/caches.mp4 rename to frontend/src/domain/help/videos/caches.mp4 diff --git a/frontend/src/domain/home/videos/hosts.mp4 b/frontend/src/domain/help/videos/hosts.mp4 similarity index 100% rename from frontend/src/domain/home/videos/hosts.mp4 rename to frontend/src/domain/help/videos/hosts.mp4 diff --git a/frontend/src/domain/home/videos/integrations.mp4 b/frontend/src/domain/help/videos/integrations.mp4 similarity index 100% rename from frontend/src/domain/home/videos/integrations.mp4 rename to frontend/src/domain/help/videos/integrations.mp4 diff --git a/frontend/src/domain/home/videos/logs.mp4 b/frontend/src/domain/help/videos/logs.mp4 similarity index 100% rename from frontend/src/domain/home/videos/logs.mp4 rename to frontend/src/domain/help/videos/logs.mp4 diff --git a/frontend/src/domain/home/videos/settings.mp4 b/frontend/src/domain/help/videos/settings.mp4 similarity index 100% rename from frontend/src/domain/home/videos/settings.mp4 rename to frontend/src/domain/help/videos/settings.mp4 diff --git a/frontend/src/domain/home/videos/ssl-certificates.mp4 b/frontend/src/domain/help/videos/ssl-certificates.mp4 similarity index 100% rename from frontend/src/domain/home/videos/ssl-certificates.mp4 rename to frontend/src/domain/help/videos/ssl-certificates.mp4 diff --git a/frontend/src/domain/home/videos/streams.mp4 b/frontend/src/domain/help/videos/streams.mp4 similarity index 100% rename from frontend/src/domain/home/videos/streams.mp4 rename to frontend/src/domain/help/videos/streams.mp4 diff --git a/frontend/src/domain/home/videos/vpns.mp4 b/frontend/src/domain/help/videos/vpns.mp4 similarity index 100% rename from frontend/src/domain/home/videos/vpns.mp4 rename to frontend/src/domain/help/videos/vpns.mp4 diff --git a/frontend/src/domain/home/HomePage.css b/frontend/src/domain/home/HomePage.css index d23b8a197..2e4bdd46e 100644 --- a/frontend/src/domain/home/HomePage.css +++ b/frontend/src/domain/home/HomePage.css @@ -1,83 +1,286 @@ -.home-guide-container h1 { - font-size: 38px; - margin: 0; - text-align: center; +.home-dashboard-container { + gap: 36px; + padding: 0 40px 16px; +} + +.home-dashboard-overview-row { + gap: 24px; + width: 100%; + display: grid; + align-items: stretch; +} + +.home-dashboard-totals-section { + min-width: 0; +} + +.home-dashboard-overview-row-count-1 { + grid-template-columns: repeat(1, minmax(180px, 1fr)); +} + +.home-dashboard-overview-row-count-2 { + grid-template-columns: repeat(2, minmax(180px, 1fr)); +} + +.home-dashboard-overview-row-count-3 { + grid-template-columns: repeat(3, minmax(180px, 1fr)); +} + +.home-dashboard-overview-row-with-nginx.home-dashboard-overview-row-count-0 { + grid-template-columns: repeat(1, minmax(180px, 1fr)); +} + +.home-dashboard-overview-row-with-nginx.home-dashboard-overview-row-count-1 { + grid-template-columns: repeat(2, minmax(180px, 1fr)); +} + +.home-dashboard-overview-row-with-nginx.home-dashboard-overview-row-count-2 { + grid-template-columns: repeat(3, minmax(180px, 1fr)); } -.home-guide-subtitle { - margin: 10px 0 0 !important; - font-size: 28px !important; - text-align: center; +.home-dashboard-overview-row-with-nginx.home-dashboard-overview-row-count-3 { + grid-template-columns: repeat(4, minmax(180px, 1fr)); } -.home-guide-header-container { - background: var(--nginxIgnition-shellHeaderBg, var(--nginxIgnition-colorBgLayout)); - padding: 120px 40px; - margin-bottom: 50px; +.home-dashboard-overview-row-count-1 .home-dashboard-totals-section { + grid-column: span 1; } -.home-guide-container h2 { - font-size: 28px; - margin-top: 20px; +.home-dashboard-overview-row-count-2 .home-dashboard-totals-section { + grid-column: span 2; } -.home-guide-container p { +.home-dashboard-overview-row-count-3 .home-dashboard-totals-section { + grid-column: span 3; +} + +.home-dashboard-overview-row-count-1 .home-dashboard-totals-section .traffic-stats-cards-row { + grid-template-columns: repeat(1, minmax(180px, 1fr)); +} + +.home-dashboard-overview-row-count-2 .home-dashboard-totals-section .traffic-stats-cards-row { + grid-template-columns: repeat(2, minmax(180px, 1fr)); +} + +.home-dashboard-overview-row-count-3 .home-dashboard-totals-section .traffic-stats-cards-row { + grid-template-columns: repeat(3, minmax(180px, 1fr)); +} + +.home-dashboard-totals-section .traffic-stats-cards-row { + display: grid; + gap: 16px; + align-items: stretch; + flex: 1; + min-width: 280px; +} + +.home-dashboard-nginx-section { + grid-column: span 1; + min-width: 180px; +} + +.home-dashboard-nginx-slot { + display: flex; + flex: 1; + min-width: 180px; +} + +.home-dashboard-nginx-control { + padding: 5px 0 0; + border-radius: 8px; + background: transparent; + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; + min-width: 180px; + height: 100%; + box-sizing: border-box; +} + +.home-dashboard-section { + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 4px; +} + +.home-dashboard-section-header { + align-items: center; + justify-content: space-between; + margin-top: 4px; +} + +.home-dashboard-view-all-link, +.home-dashboard-view-all-link:hover, +.home-dashboard-view-all-link:focus, +.home-dashboard-view-all-link:active { + display: inline-flex; + align-items: center; + padding: 1px 8px; + font-size: 12px; + font-weight: 400; + line-height: 20px; + color: var(--nginxIgnition-colorTextSecondary); + background: var(--nginxIgnition-colorBgLayout); + border: 1px solid var(--nginxIgnition-colorBorderSecondary); + border-radius: 4px; + text-decoration: none; + transition: + background 0.2s, + border-color 0.2s, + color 0.2s; +} + +.home-dashboard-view-all-link:hover, +.home-dashboard-view-all-link:focus { + color: var(--nginxIgnition-colorTextTertiary); + background: var(--nginxIgnition-colorBgElevated); +} + +html:not([data-theme="dark"]) .home-dashboard-view-all-link, +html:not([data-theme="dark"]) .home-dashboard-view-all-link:focus, +html:not([data-theme="dark"]) .home-dashboard-view-all-link:active { + color: var(--nginxIgnition-buttonDefaultText) !important; + background: var(--nginxIgnition-buttonDefaultBg) !important; + border-color: var(--nginxIgnition-buttonDefaultBorder) !important; +} + +html:not([data-theme="dark"]) .home-dashboard-view-all-link:hover { + color: var(--nginxIgnition-buttonDefaultHoverText) !important; + background: var(--nginxIgnition-buttonDefaultBgHover) !important; + border-color: var(--nginxIgnition-fieldBorderHover) !important; +} + +.home-dashboard-section-title { font-size: 16px; - margin: 10px 0; - padding: 0; + font-weight: 500; + margin: 0; + color: var(--nginxIgnition-colorTextSecondary); } -.home-guide-right-side-video, -.home-guide-left-side-video { - flex-grow: 0; - flex-shrink: 0; - width: 40%; +html:not([data-theme="dark"]) .home-dashboard-section-title { + color: var(--nginxIgnition-colorTextTertiary); } -.home-guide-container video { - width: 100%; +.home-dashboard-nginx-status { + font-size: 14px; + font-weight: 500; + line-height: 1.4; + gap: 8px; +} + +.home-dashboard-nginx-status-border { + width: 8px; + flex-shrink: 0; + align-self: stretch; + border-radius: 4px; } -.home-guide-right-side-video { - margin: 0 0 0 40px; +.home-dashboard-split-row { + gap: 24px; + flex-wrap: wrap; + align-items: stretch; } -.home-guide-left-side-video { - margin: 0 40px 0 0; +.home-dashboard-details-column { + flex: 1 1 320px; + min-width: 320px; + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 4px; } -.home-guide-video-mask { - border-radius: 10px; +.home-dashboard-panel, +.home-dashboard-log-content { + flex: 1; + min-height: 0; + max-height: 281px; + display: flex; + flex-direction: column; overflow: hidden; - height: fit-content; - line-height: 0; } -.home-guide-video-mask video { - margin: 0; +.home-dashboard-panel { + padding: 16px; + background: var(--nginxIgnition-colorBgLayout); + border-radius: 8px; +} + +.home-dashboard-details-column .home-dashboard-panel .home-dashboard-cert-list { + flex: 1; + min-height: 0; + overflow: auto; +} + +.home-dashboard-log-content .log-viewer-container { + flex: 1; + min-height: 0; + overflow: auto; +} + +.home-dashboard-panel .home-dashboard-empty, +.home-dashboard-log-content .home-dashboard-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; padding: 0; - line-height: 0 !important; + min-height: 0; } -.home-guide-section { - margin-bottom: 80px; - padding: 0 40px; +.home-dashboard-cert-list { + gap: 8px; } -.home-guide-section-content { - flex-grow: 1; - width: 60%; +.home-dashboard-cert-item { + justify-content: space-between; + padding: 8px 12px; + background: var(--nginxIgnition-colorBgContainer); + border-radius: 6px; } -.home-guide-footer-container { - background: var(--nginxIgnition-shellHeaderBg, var(--nginxIgnition-colorBgLayout)); - padding: 60px 40px; +.home-dashboard-cert-domains { + font-weight: 500; + color: inherit; } -.home-guide-footer-container h1 { - font-size: 24px; +.home-dashboard-cert-expiry { + color: var(--nginxIgnition-colorTextSecondary); + white-space: nowrap; + margin-left: 12px; } -.home-guide-footer-container p { - font-size: 18px !important; +.home-dashboard-traffic-panel { + min-height: auto; +} + +.home-dashboard-empty { + padding: 16px 0; +} + +.home-dashboard-section:not(.home-dashboard-totals-section) .traffic-stats-cards-row { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: stretch; + flex: 1; +} + +.home-dashboard-section:not(.home-dashboard-totals-section) .traffic-stats-stat-card-link, +.home-dashboard-totals-section .traffic-stats-stat-card-link { + display: flex; + flex-direction: column; + flex: 1; + min-width: 180px; +} + +.home-dashboard-section .traffic-stats-stat-card { + flex: 1; + min-width: 180px; + width: 100%; + height: 100%; + box-sizing: border-box; } diff --git a/frontend/src/domain/home/HomePage.tsx b/frontend/src/domain/home/HomePage.tsx index 3645846a5..a3f64e55a 100644 --- a/frontend/src/domain/home/HomePage.tsx +++ b/frontend/src/domain/home/HomePage.tsx @@ -1,225 +1,464 @@ import React from "react" +import { Empty, Flex } from "antd" import AppShellContext from "../../core/components/shell/AppShellContext" -import "./HomePage.css" -import { - AuditOutlined, - BlockOutlined, - FileProtectOutlined, - FileSearchOutlined, - HddOutlined, - MergeCellsOutlined, - SettingOutlined, - ApartmentOutlined, - RocketOutlined, -} from "@ant-design/icons" -import { Flex } from "antd" -import Videos from "./videos/Videos" -import { Link } from "react-router-dom" +import Preloader from "../../core/components/preloader/Preloader" +import EmptyStates from "../../core/components/emptystate/EmptyStates" +import { isAccessGranted } from "../../core/components/accesscontrol/IsAccessGranted" +import { UserAccessLevel } from "../user/model/UserAccessLevel" import MessageKey from "../../core/i18n/model/MessageKey.generated" -import { I18n } from "../../core/i18n/I18n" +import { I18n, I18nMessage } from "../../core/i18n/I18n" +import NginxService from "../nginx/NginxService" +import NginxMetadata, { NginxSupportType } from "../nginx/model/NginxMetadata" +import HostService from "../host/HostService" +import StreamService from "../stream/StreamService" +import CertificateService from "../certificate/CertificateService" +import { CertificateResponse } from "../certificate/model/CertificateResponse" +import SettingsService from "../settings/SettingsService" +import SettingsDto from "../settings/model/SettingsDto" +import TrafficStatsService from "../trafficstats/TrafficStatsService" +import TrafficStatsResponse, { ZoneData } from "../trafficstats/model/TrafficStatsResponse" +import ZoneStatCards from "../trafficstats/components/ZoneStatCards" +import LogViewer from "../logs/components/LogViewer" +import LogLine from "../logs/model/LogLine" +import { Link } from "react-router-dom" +import TagGroup from "../../core/components/taggroup/TagGroup" +import CountCard from "./components/CountCard" +import HomeHeader from "./components/HomeHeader" +import NginxStatusCard from "./components/NginxStatusCard" +import "./HomePage.css" +import "../trafficstats/TrafficStatsPage.css" +import "../logs/components/LogViewer.css" + +interface HomePageState { + loading: boolean + refreshToken: number + metadata?: NginxMetadata + nginxRunning?: boolean + settings?: SettingsDto + hostCount?: number + streamCount?: number + certificateCount?: number + expiringCertificates: CertificateResponse[] + errorLogs: LogLine[] + stats?: TrafficStatsResponse + error?: Error +} + +export default class HomePage extends React.Component { + private readonly nginxService: NginxService + private readonly hostService: HostService + private readonly streamService: StreamService + private readonly certificateService: CertificateService + private readonly settingsService: SettingsService + private readonly trafficStatsService: TrafficStatsService + + constructor(props: object) { + super(props) + this.nginxService = new NginxService() + this.hostService = new HostService() + this.streamService = new StreamService() + this.certificateService = new CertificateService() + this.settingsService = new SettingsService() + this.trafficStatsService = new TrafficStatsService() + this.state = { + loading: true, + refreshToken: 0, + expiringCertificates: [], + errorLogs: [], + } + } -export default class HomePage extends React.PureComponent { componentDidMount() { + this.configureShell() + this.fetchData() + } + + private canViewNginxServer(): boolean { + return isAccessGranted(UserAccessLevel.READ_ONLY, permissions => permissions.nginxServer) + } + + private canViewHosts(): boolean { + return isAccessGranted(UserAccessLevel.READ_ONLY, permissions => permissions.hosts) + } + + private canViewStreams(): boolean { + return isAccessGranted(UserAccessLevel.READ_ONLY, permissions => permissions.streams) + } + + private canViewCertificates(): boolean { + return isAccessGranted(UserAccessLevel.READ_ONLY, permissions => permissions.certificates) + } + + private canViewLogs(): boolean { + return isAccessGranted(UserAccessLevel.READ_ONLY, permissions => permissions.logs) + } + + private canViewTrafficStats(): boolean { + return isAccessGranted(UserAccessLevel.READ_ONLY, permissions => permissions.trafficStats) + } + + private configureShell() { AppShellContext.get().updateConfig({ noContainerPadding: true, }) } - render() { + private refreshData() { + const { loading } = this.state + if (loading) return + + this.setState( + state => ({ error: undefined, refreshToken: state.refreshToken + 1 }), + () => this.fetchData(), + ) + } + + private filterExpiringCertificates(certificates: CertificateResponse[]): CertificateResponse[] { + const now = new Date() + const windowEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) + + return certificates + .filter(certificate => { + const validUntil = new Date(certificate.validUntil) + return validUntil >= now && validUntil <= windowEnd + }) + .sort((left, right) => new Date(left.validUntil).getTime() - new Date(right.validUntil).getTime()) + } + + private daysUntilExpiry(validUntil: string): number { + const now = new Date() + now.setHours(0, 0, 0, 0) + const expiry = new Date(validUntil) + expiry.setHours(0, 0, 0, 0) + return Math.ceil((expiry.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)) + } + + private overviewClassName(countCardsLength: number, canViewNginx: boolean): string { + let className = `home-dashboard-overview-row home-dashboard-overview-row-count-${countCardsLength}` + + if (canViewNginx) className += " home-dashboard-overview-row-with-nginx" + + return className + } + + private async fetchData() { + try { + const canNginxServer = this.canViewNginxServer() + const canHosts = this.canViewHosts() + const canStreams = this.canViewStreams() + const canCertificates = this.canViewCertificates() + const canLogs = this.canViewLogs() + const canTrafficStats = this.canViewTrafficStats() + const needsMetadata = canNginxServer || canTrafficStats + const needsSettings = canLogs || canTrafficStats + + const [metadata, nginxRunning, settings, hostsPage, streamsPage, certificates] = await Promise.all([ + needsMetadata ? this.nginxService.getMetadata() : Promise.resolve(undefined), + needsMetadata ? this.nginxService.isRunning() : Promise.resolve(undefined), + needsSettings ? this.settingsService.get() : Promise.resolve(undefined), + canHosts ? this.hostService.list(1, 0) : Promise.resolve(undefined), + canStreams ? this.streamService.list(1, 0) : Promise.resolve(undefined), + canCertificates ? this.certificateService.listAll() : Promise.resolve([]), + ]) + + let errorLogs: LogLine[] = [] + if (canLogs && settings?.nginx.logs.serverLogsEnabled) { + errorLogs = await this.nginxService.logs(15, 0) + } + + let stats: TrafficStatsResponse | undefined + const statsSupported = metadata?.availableSupport.stats !== NginxSupportType.NONE + const statsEnabled = metadata?.stats.enabled === true + if (canTrafficStats && statsSupported && statsEnabled && nginxRunning) { + stats = await this.trafficStatsService.getStats() + } + + this.setState({ + loading: false, + error: undefined, + metadata, + nginxRunning, + settings, + hostCount: hostsPage?.totalItems, + streamCount: streamsPage?.totalItems, + certificateCount: canCertificates ? certificates.length : undefined, + expiringCertificates: canCertificates ? this.filterExpiringCertificates(certificates) : [], + errorLogs, + stats, + }) + } catch (error) { + this.setState({ loading: false, error: error as Error }) + } + } + + private renderOverviewSection() { + const { hostCount, streamCount, certificateCount } = this.state + const countCards: React.ReactNode[] = [] + + if (this.canViewHosts() && hostCount !== undefined) { + countCards.push() + } + + if (this.canViewStreams() && streamCount !== undefined) { + countCards.push( + , + ) + } + + if (this.canViewCertificates() && certificateCount !== undefined) { + countCards.push( + , + ) + } + + const canViewNginx = this.canViewNginxServer() + if (countCards.length === 0 && !canViewNginx) return null + return ( -
-
-

- -

-

- -

+ + {this.renderOverviewTotalsSection(countCards)} + {this.renderOverviewNginxSection(canViewNginx)} + + ) + } + + private renderOverviewTotalsSection(countCards: React.ReactNode[]) { + if (countCards.length === 0) return null + + return ( +
+

+ +

+ {countCards} +
+ ) + } + + private renderOverviewNginxSection(canViewNginx: boolean) { + if (!canViewNginx) return null + + const { refreshToken } = this.state + + return ( +
+

+ +

+
+
+
+ ) + } - - -

- -

-

- -

-

- -

-

- -

-
- -
-
-
-
+ private renderDashboardEmpty(message: I18nMessage) { + return } /> + } - - -
-
-
- -

- -

-

- -

-

- -

-
-
+ private renderTrafficEmptyState() { + const { metadata, nginxRunning, stats } = this.state - - -

- -

-

- -

-

- -

-

- -

-
- -
-
-
-
+ if (metadata?.availableSupport?.stats === NginxSupportType.NONE) { + return this.renderDashboardEmpty(MessageKey.FrontendHomeTrafficUnsupported) + } - - -
-
-
- -

- -

-

- -

-

- -

-
-
+ if (metadata && !metadata.stats.enabled) { + return this.renderDashboardEmpty(MessageKey.FrontendHomeTrafficDisabled) + } - - -

- -

-

- -

-
- -
-
-
-
+ if (nginxRunning === false) { + return this.renderDashboardEmpty(MessageKey.FrontendHomeTrafficOffline) + } - - -
-
-
- -

- -

-

- -

-
-
+ if (stats?.serverZones?.["*"]?.requestCounter === 0) { + return this.renderDashboardEmpty(MessageKey.FrontendHomeTrafficNoData) + } - - -

- -

-

- -

-
- -
-
-
-
+ return null + } - - -
-
-
- -

- -

-

- -

-

- -

-
-
+ private renderViewAllLink(to: string) { + return ( + + + + ) + } + + private renderTrafficSectionHeader(emptyState: React.ReactNode | null) { + return ( + +

+ +

+ {emptyState === null && this.renderViewAllLink("/traffic-stats")} +
+ ) + } + + private renderTrafficSectionContent(emptyState: React.ReactNode | null, globalZone: ZoneData | undefined) { + if (emptyState !== null) { + return
{emptyState}
+ } + + if (globalZone === undefined) return null + + return ( + + ) + } + + private renderTrafficSection() { + if (!this.canViewTrafficStats()) return null + + const { stats } = this.state + const globalZone = stats?.serverZones?.["*"] + const emptyState = this.renderTrafficEmptyState() + + return ( +
+ {this.renderTrafficSectionHeader(emptyState)} + {this.renderTrafficSectionContent(emptyState, globalZone)} +
+ ) + } + + private renderCertificateExpiryMessage(days: number) { + if (days <= 0) return + + return + } + + private renderExpiringCertificateItem(certificate: CertificateResponse) { + const days = this.daysUntilExpiry(certificate.validUntil) + const expiryMessage = this.renderCertificateExpiryMessage(days) + + return ( + + + + + {expiryMessage} + + ) + } + + private renderExpiringCertificatesContent() { + const { expiringCertificates } = this.state + + if (expiringCertificates.length === 0) { + return this.renderDashboardEmpty(MessageKey.FrontendHomeNoCertificatesExpiring) + } + + return ( + + {expiringCertificates.map(certificate => this.renderExpiringCertificateItem(certificate))} + + ) + } - - -

- -

-

- -

-

- -

-

- -

-
- -
-
-
+ private renderExpiringCertificatesPanel() { + if (!this.canViewCertificates()) return null + + return ( +
+ +

+ +

+ {this.renderViewAllLink("/certificates")}
+
{this.renderExpiringCertificatesContent()}
+
+ ) + } -
-

- -

-

- - - - . -

-
+ private renderRecentErrorsContent() { + const { settings, errorLogs } = this.state + const serverLogsEnabled = settings?.nginx.logs.serverLogsEnabled === true + + if (!serverLogsEnabled) { + return this.renderDashboardEmpty(MessageKey.FrontendHomeRecentErrorsDisabled) + } + + if (errorLogs.length === 0) { + return this.renderDashboardEmpty(MessageKey.FrontendHomeRecentErrorsEmpty) + } + + return + } + + private renderRecentErrorsBody(content: React.ReactNode) { + const serverLogsEnabled = this.state.settings?.nginx.logs.serverLogsEnabled === true + + if (serverLogsEnabled) { + return
{content}
+ } + + return
{content}
+ } + + private renderRecentErrorsPanel() { + if (!this.canViewLogs()) return null + + const content = this.renderRecentErrorsContent() + + return ( +
+ +

+ +

+ {this.renderViewAllLink("/logs")} +
+ {this.renderRecentErrorsBody(content)}
) } + + private renderDetailsSection() { + const expiringPanel = this.renderExpiringCertificatesPanel() + const errorsPanel = this.renderRecentErrorsPanel() + + if (expiringPanel === null && errorsPanel === null) return null + + return ( + + {expiringPanel} + {errorsPanel} + + ) + } + + render() { + const { loading, error } = this.state + + if (error !== undefined) return EmptyStates.FailedToFetch + + if (loading) return + + const { metadata } = this.state + + return ( + <> + this.refreshData()} /> + + {this.renderOverviewSection()} + {this.renderTrafficSection()} + {this.renderDetailsSection()} + + + ) + } } diff --git a/frontend/src/domain/home/components/CountCard.tsx b/frontend/src/domain/home/components/CountCard.tsx new file mode 100644 index 000000000..2f4011c3a --- /dev/null +++ b/frontend/src/domain/home/components/CountCard.tsx @@ -0,0 +1,24 @@ +import React from "react" +import { Statistic } from "antd" +import { Link } from "react-router-dom" +import { I18n, I18nMessage } from "../../../core/i18n/I18n" + +export interface CountCardProps { + title: I18nMessage + count: number + linkTo: string +} + +export default class CountCard extends React.PureComponent { + render() { + const { title, count, linkTo } = this.props + + return ( + +
+ } value={count} /> +
+ + ) + } +} diff --git a/frontend/src/domain/home/components/HomeHeader.css b/frontend/src/domain/home/components/HomeHeader.css new file mode 100644 index 000000000..39110e45b --- /dev/null +++ b/frontend/src/domain/home/components/HomeHeader.css @@ -0,0 +1,72 @@ +.home-header { + background: var(--nginxIgnition-shellHeaderBg, var(--nginxIgnition-colorBgLayout)); + padding: 48px 40px; + margin-bottom: 36px; + width: 100%; + box-sizing: border-box; +} + +.home-header-columns { + gap: 24px; + width: 100%; +} + +.home-header-greeting { + flex: 1; + min-width: 240px; + gap: 10px; +} + +.home-header-title { + font-size: 32px; + font-weight: 600; + margin: 0; + line-height: 1.2; +} + +.home-header-subtitle { + font-size: 22px; + font-weight: 400; + margin: 0; + color: var(--nginxIgnition-colorTextSecondary); + line-height: 1.4; +} + +html:not([data-theme="dark"]) .home-header-subtitle { + color: var(--nginxIgnition-colorTextTertiary); +} + +.home-header-sidebar { + gap: 0; + min-width: 200px; +} + +.home-header-meta-group { + gap: 2px; + line-height: 1.2; +} + +.home-header-actions { + margin-top: 20px; +} + +.home-header-update-button.ant-btn { + color: var(--ant-color-warning-text) !important; + background: var(--ant-color-warning-bg) !important; + border-color: var(--ant-color-warning-border) !important; +} + +.home-header-update-button.ant-btn:hover:not(:disabled) { + color: var(--nginxIgnition-colorWarning) !important; + background: var(--ant-color-warning-bg) !important; + border-color: var(--nginxIgnition-colorWarning) !important; +} + +.home-header-meta-line { + font-size: 14px; + color: var(--nginxIgnition-colorTextSecondary); +} + +html:not([data-theme="dark"]) .home-header-meta-line { + color: var(--nginxIgnition-colorTextTertiary); +} diff --git a/frontend/src/domain/home/components/HomeHeader.tsx b/frontend/src/domain/home/components/HomeHeader.tsx new file mode 100644 index 000000000..3d46a50ee --- /dev/null +++ b/frontend/src/domain/home/components/HomeHeader.tsx @@ -0,0 +1,109 @@ +import React from "react" +import { Button, Flex } from "antd" +import AppContext from "../../../core/components/context/AppContext" +import MessageKey from "../../../core/i18n/model/MessageKey.generated" +import { I18n } from "../../../core/i18n/I18n" +import NginxMetadata from "../../nginx/model/NginxMetadata" +import "./HomeHeader.css" +import If from "../../../core/components/flowcontrol/If" + +export interface HomeHeaderProps { + metadata?: NginxMetadata + onRefresh: () => void +} + +export default class HomeHeader extends React.PureComponent { + private firstName(): string { + const user = AppContext.get().user + if (user === undefined) return "" + + const trimmedName = user.name.trim() + if (trimmedName.length > 0) return trimmedName.split(/\s+/)[0] + + return user.username + } + + private releaseUrl(latest: string): string { + return `https://github.com/lucasdillmann/nginx-ignition/releases/${latest}` + } + + private renderAppVersion() { + const { current } = AppContext.get().configuration.version + + return ( + + + + ) + } + + private renderNginxVersion() { + const { metadata } = this.props + if (metadata === undefined) return null + + return ( + + + + ) + } + + private renderActions(onRefresh: () => void) { + const { current, latest } = AppContext.get().configuration.version + const updateAvailable = Boolean(current) && Boolean(latest) && current !== latest + + return ( + + + + + + + ) + } + + render() { + const { onRefresh } = this.props + + return ( +
+ + +

+ +

+

+ +

+
+ + + {this.renderAppVersion()} + {this.renderNginxVersion()} + + {this.renderActions(onRefresh)} + +
+
+ ) + } +} diff --git a/frontend/src/domain/home/components/NginxStatusCard.tsx b/frontend/src/domain/home/components/NginxStatusCard.tsx new file mode 100644 index 000000000..4e5c1a7f6 --- /dev/null +++ b/frontend/src/domain/home/components/NginxStatusCard.tsx @@ -0,0 +1,203 @@ +import React from "react" +import { Button, ConfigProvider, Flex } from "antd" +import Preloader from "../../../core/components/preloader/Preloader" +import NginxService from "../../nginx/NginxService" +import NginxEventDispatcher from "../../nginx/listener/NginxEventDispatcher" +import { NginxEventListener } from "../../nginx/listener/NginxEventListener" +import UserConfirmation from "../../../core/components/confirmation/UserConfirmation" +import GenericNginxAction, { ActionType } from "../../nginx/actions/GenericNginxAction" +import { isAccessGranted } from "../../../core/components/accesscontrol/IsAccessGranted" +import { UserAccessLevel } from "../../user/model/UserAccessLevel" +import MessageKey from "../../../core/i18n/model/MessageKey.generated" +import { I18n, I18nMessage, i18n } from "../../../core/i18n/I18n" +import If from "../../../core/components/flowcontrol/If" + +interface NginxStatusCardProps { + refreshToken: number +} + +interface NginxStatusCardState { + loading: boolean + running?: boolean + uptimeSeconds?: number +} + +export default class NginxStatusCard extends React.Component { + private readonly service: NginxService + private readonly listener: NginxEventListener + + constructor(props: NginxStatusCardProps) { + super(props) + this.service = new NginxService() + this.state = { loading: true } + this.listener = () => this.handleNginxEvent() + } + + componentDidMount() { + NginxEventDispatcher.register(this.listener) + this.refreshStatus() + } + + componentWillUnmount() { + NginxEventDispatcher.remove(this.listener) + } + + componentDidUpdate(previousProps: NginxStatusCardProps) { + if (previousProps.refreshToken !== this.props.refreshToken) { + this.refreshStatus() + } + } + + private handleNginxEvent() { + const { loading } = this.state + if (loading) return + + this.setState({ loading: true }, () => this.refreshStatus()) + } + + private refreshStatus() { + this.service + .getStatus() + .catch(() => undefined) + .then(status => + this.setState({ + running: status?.running, + uptimeSeconds: status?.uptimeSeconds, + loading: false, + }), + ) + } + + private formatCountUnit(count: number, singularKey: I18nMessage, pluralKey: I18nMessage): string { + const unit = i18n(count === 1 ? singularKey : pluralKey) + return `${count} ${unit}` + } + + private uptimeLabelParams(totalSeconds: number): { days: string; hours: string; minutes: string; seconds: string } { + const dayCount = Math.floor(totalSeconds / 86400) + const hourCount = Math.floor((totalSeconds % 86400) / 3600) + const minuteCount = Math.floor((totalSeconds % 3600) / 60) + const secondCount = totalSeconds % 60 + + return { + days: this.formatCountUnit(dayCount, MessageKey.CommonTimeUnitDay, MessageKey.CommonTimeUnitDays), + hours: this.formatCountUnit(hourCount, MessageKey.CommonTimeUnitHour, MessageKey.CommonTimeUnitHours), + minutes: this.formatCountUnit( + minuteCount, + MessageKey.CommonTimeUnitMinute, + MessageKey.CommonTimeUnitMinutes, + ), + seconds: this.formatCountUnit(secondCount, MessageKey.CommonTimeUnitSecond, MessageKey.CommonUnitSeconds), + } + } + + private statusMetadata(): { color: string; label: I18nMessage } { + const { running } = this.state + + if (running === undefined) { + return { + color: "var(--nginxIgnition-colorWarning)", + label: MessageKey.FrontendHomeNginxUnknown, + } + } + + if (running) { + return { + color: "var(--nginxIgnition-colorSuccess)", + label: MessageKey.FrontendHomeNginxOnline, + } + } + + return { + color: "var(--nginxIgnition-colorError)", + label: MessageKey.FrontendHomeNginxOffline, + } + } + + private confirmStop() { + UserConfirmation.ask(MessageKey.FrontendHomeNginxStopConfirmation).then(() => { + this.performAction(ActionType.STOP) + }) + } + + private performAction(action: ActionType) { + this.setState({ loading: true }, () => { + new GenericNginxAction(action, "nginxIgnition.homeDashboard") + .execute() + .catch(() => {}) + .then(() => this.refreshStatus()) + }) + } + + private renderStatus() { + const { running, uptimeSeconds } = this.state + const { color, label } = this.statusMetadata() + const showUptime = running === true && Boolean(uptimeSeconds) + + return ( + + + + + + + + + + + + ) + } + + private renderActions() { + const { running } = this.state + const readOnly = !isAccessGranted(UserAccessLevel.READ_WRITE, permissions => permissions.nginxServer) + + if (!running) { + return ( + + ) + } + + return ( + + + + + ) + } + + render() { + const { loading } = this.state + + return ( + +
+ {this.renderStatus()} + {this.renderActions()} +
+
+ ) + } +} diff --git a/frontend/src/domain/logs/LogsPage.tsx b/frontend/src/domain/logs/LogsPage.tsx index 07ed86cfb..7d7a6e418 100644 --- a/frontend/src/domain/logs/LogsPage.tsx +++ b/frontend/src/domain/logs/LogsPage.tsx @@ -188,7 +188,7 @@ export default class LogsPage extends React.Component { private buildAutoRefreshOptions() { return [1, 5, 10, 30, 60].map(item => ({ - label: , + label: , value: item, })) } diff --git a/frontend/src/domain/nginx/NginxService.ts b/frontend/src/domain/nginx/NginxService.ts index 5d80932f4..433e7e4ca 100644 --- a/frontend/src/domain/nginx/NginxService.ts +++ b/frontend/src/domain/nginx/NginxService.ts @@ -4,6 +4,7 @@ import NginxEventDispatcher from "./listener/NginxEventDispatcher" import { NginxOperation } from "./listener/NginxEventListener" import NginxMetadata from "./model/NginxMetadata" import LogLine from "../logs/model/LogLine" +import { NginxStatusResponse } from "./model/NginxStatusResponse" export default class NginxService { private readonly gateway: NginxGateway @@ -13,10 +14,11 @@ export default class NginxService { } async isRunning(): Promise { - return this.gateway - .getStatus() - .then(requireSuccessPayload) - .then(response => response.running) + return this.getStatus().then(response => response.running) + } + + async getStatus(): Promise { + return this.gateway.getStatus().then(requireSuccessPayload) } async getMetadata(): Promise { diff --git a/frontend/src/domain/nginx/components/NginxControl.css b/frontend/src/domain/nginx/components/NginxControl.css index 1ee06ca1e..aa2aa3f53 100644 --- a/frontend/src/domain/nginx/components/NginxControl.css +++ b/frontend/src/domain/nginx/components/NginxControl.css @@ -27,99 +27,20 @@ margin-left: 5px; } -html:not([data-theme="dark"]) .nginx-control-container .nginx-status-line .ant-btn-variant-outlined { - background: rgba(255, 255, 255, 0.03); -} - -html:not([data-theme="dark"]) .nginx-control-container .nginx-status-line .ant-btn-variant-outlined:hover { - background: rgba(255, 255, 255, 0.07); -} - -html:not([data-theme="dark"]) - .nginx-control-container - .nginx-reload-button.ant-btn-color-primary.ant-btn-variant-outlined { - color: #aebecd; - border-color: #aebecd; -} - -html:not([data-theme="dark"]) - .nginx-control-container - .nginx-reload-button.ant-btn-color-primary.ant-btn-variant-outlined:hover { - color: #c2ced9; - border-color: #c2ced9; -} - -html:not([data-theme="dark"]) - .nginx-control-container - .nginx-status-line - .ant-btn-color-primary.ant-btn-variant-outlined:not(.nginx-reload-button) { - color: #aebecd; - border-color: #aebecd; -} - -html:not([data-theme="dark"]) - .nginx-control-container - .nginx-status-line - .ant-btn-color-primary.ant-btn-variant-outlined:not(.nginx-reload-button):hover { - color: #c2ced9; - border-color: #c2ced9; -} - html:not([data-theme="dark"]) .nginx-control-container - .nginx-status-line - .ant-btn-color-dangerous.ant-btn-variant-outlined { - color: #c2a6aa; - border-color: #c2a6aa; + .nginx-reload-button.ant-btn-color-primary.ant-btn-variant-solid { + color: #eef2f6 !important; + background: #627180 !important; + border: none !important; } html:not([data-theme="dark"]) .nginx-control-container - .nginx-status-line - .ant-btn-color-dangerous.ant-btn-variant-outlined:hover { - color: #d2bcc0; - border-color: #d2bcc0; -} - -html[data-theme="dark"] .nginx-control-container .nginx-reload-button.ant-btn-color-primary.ant-btn-variant-outlined { - color: #8c9aa7; - border-color: #8c9aa7; -} - -html[data-theme="dark"] - .nginx-control-container - .nginx-status-line - .ant-btn-color-primary.ant-btn-variant-outlined:not(.nginx-reload-button) { - color: #8c9aa7; - border-color: #8c9aa7; -} - -html[data-theme="dark"] - .nginx-control-container - .nginx-status-line - .ant-btn-color-primary.ant-btn-variant-outlined:not(.nginx-reload-button):hover { - color: #98a6b2; - border-color: #98a6b2; -} - -html[data-theme="dark"] .nginx-control-container .nginx-status-line .ant-btn-color-dangerous.ant-btn-variant-outlined { - color: #b49399; - border-color: #b49399; -} - -html[data-theme="dark"] - .nginx-control-container - .nginx-status-line - .ant-btn-color-dangerous.ant-btn-variant-outlined:hover { - color: #c0a0a5; - border-color: #c0a0a5; -} - -html[data-theme="dark"] - .nginx-control-container - .nginx-reload-button.ant-btn-color-primary.ant-btn-variant-outlined:hover { - color: #98a6b2; - border-color: #98a6b2; + .nginx-reload-button.ant-btn-color-primary.ant-btn-variant-solid:hover:not(:disabled) { + color: #ffffff !important; + background: #738293 !important; + border: none !important; } .nginx-control-container-collapsed { diff --git a/frontend/src/domain/nginx/components/NginxControl.tsx b/frontend/src/domain/nginx/components/NginxControl.tsx index d19cd81d0..dbb9e7699 100644 --- a/frontend/src/domain/nginx/components/NginxControl.tsx +++ b/frontend/src/domain/nginx/components/NginxControl.tsx @@ -131,7 +131,7 @@ export default class NginxControl extends React.Component this.performNginxAction(ActionType.START)} disabled={readOnly} > @@ -141,13 +141,13 @@ export default class NginxControl extends React.Component -