Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17822b7cf6 | ||
|
|
de8eb53dd4 | ||
|
|
dbe0e2f4e3 | ||
|
|
3ddc6ef1cb | ||
|
|
ac94ae0fdd | ||
|
|
acbf26b03d | ||
|
|
f931954997 | ||
|
|
2cfc2915ce | ||
|
|
d965a82cc9 | ||
|
|
62fb17f64c | ||
|
|
8582958ad5 | ||
|
|
1372e66749 | ||
|
|
bdad949170 | ||
|
|
e69a2f05af | ||
|
|
a1c54997bb | ||
|
|
5657d39589 | ||
|
|
cdd4953e03 | ||
|
|
786c803847 | ||
|
|
56153b38a5 | ||
|
|
f85ed902e4 | ||
|
|
8ccc7cbe9a | ||
|
|
d347e29a28 | ||
|
|
f10fb452ba | ||
|
|
fc9d2fe69d | ||
|
|
ca896fa22f | ||
|
|
a7ee90e1ee | ||
|
|
06190a885a | ||
|
|
5f07860217 | ||
|
|
073635d786 | ||
|
|
af1d964e44 | ||
|
|
3b6b7a1e5d | ||
|
|
bc44a3baeb | ||
|
|
4e4bc5f319 | ||
|
|
2fec09df62 | ||
|
|
13e49c518d | ||
|
|
a1f9c0304f | ||
|
|
d158bcdce1 | ||
|
|
c45a21b4c8 | ||
|
|
a0738dff63 | ||
|
|
3cc4db9af5 | ||
|
|
94ecbfe365 | ||
|
|
5343241036 | ||
|
|
261fdd08f1 | ||
|
|
5470f44ebb | ||
|
|
b24e42e516 | ||
|
|
09d20c1bc7 | ||
|
|
d59bdf6c0c | ||
|
|
9d0f0116f3 | ||
|
|
3e56dc63a9 | ||
|
|
30881a0af1 | ||
|
|
ebbf1a8b1e | ||
|
|
9a6602b9e1 | ||
|
|
46211ac61d | ||
|
|
791a0c8483 | ||
|
|
e964234f54 | ||
|
|
e60bfd18ff | ||
|
|
560d64a482 | ||
|
|
c8826d90d5 | ||
|
|
cacedfd7d8 | ||
|
|
4ec03dfd04 | ||
|
|
78d3b23e93 | ||
|
|
1a1dc7d807 | ||
|
|
03174d98dd | ||
|
|
686ded2137 | ||
|
|
9c8bf4bb05 | ||
|
|
8f6c909acd | ||
|
|
1ca38a31c9 | ||
|
|
9ffcbd1242 | ||
|
|
840e12eb9a | ||
|
|
96b2266a9d |
No files matched your search
@@ -109,7 +109,7 @@ client/src/
|
||||
- **Data Fetching**: SWR + Axios
|
||||
- **Database**: MongoDB with Mongoose ODM
|
||||
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
|
||||
- **i18n**: i18next + react-i18next (translations via PoEditor)
|
||||
- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,5 +2,9 @@ apiVersion: v2
|
||||
name: checkmate-chart
|
||||
description: A Helm chart for Checkmate App
|
||||
type: application
|
||||
version: 0.3.0
|
||||
appVersion: "latest" # the all-in-one image publishes :latest on release tags; pin a version here to freeze
|
||||
version: 0.2.0
|
||||
# DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one
|
||||
# image (ghcr.io/bluewave-labs/checkmate). The checkmate-client / checkmate-backend /
|
||||
# checkmate-mongo images this chart references are frozen at v3.8.1 — later versions
|
||||
# are never published, so a bumped chart will fail to pull.
|
||||
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
|
||||
@@ -19,8 +19,8 @@ cd checkmate/charts/helm/checkmate
|
||||
|
||||
### 2. Customize values.yaml
|
||||
Edit `values.yaml` to update:
|
||||
- `app.ingress.host` with your domain name
|
||||
- `app.protocol` (usually http or https)
|
||||
- `client.ingress.host` and `api.ingress.host` with your domain names
|
||||
- `api.protocol` (usually http or https)
|
||||
- **If upgrading**: Migrate persistence settings from flat structure to nested:
|
||||
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
|
||||
- Add: `persistence.mongo.storageClass` (leave empty for default)
|
||||
@@ -95,7 +95,7 @@ chart. You can still pin per tier:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
image: ghcr.io/bluewave-labs/checkmate # repo only
|
||||
image: ghcr.io/bluewave-labs/checkmate-backend # repo only
|
||||
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
|
||||
```
|
||||
|
||||
@@ -244,41 +244,3 @@ kubectl get secrets | grep checkmate-tls
|
||||
```
|
||||
|
||||
The ingress will automatically use these secrets to enable HTTPS access to your Checkmate instance.
|
||||
|
||||
## Upgrading to chart 0.3.0 (all-in-one image)
|
||||
|
||||
Checkmate now ships as a single image that serves both the client and the API, so
|
||||
the chart deploys one workload and one ingress instead of two.
|
||||
|
||||
**What changed**
|
||||
|
||||
| 0.2.x | 0.3.0 |
|
||||
|---|---|
|
||||
| `client.*` + `api.*` value blocks | one `app.*` block |
|
||||
| `checkmate-client` + `checkmate-backend` images | `ghcr.io/bluewave-labs/checkmate` |
|
||||
| `ghcr.io/bluewave-labs/checkmate-mongo` | upstream `mongo:8.0` |
|
||||
| Deployment `checkmate-api` + `checkmate-client` | Deployment `checkmate` |
|
||||
| Ingress `checkmate-api-ingress` (`/api/v1`) + client ingress (`/`) | Ingress `checkmate-ingress` (`/`) |
|
||||
| `UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST` | not set — the client is same-origin |
|
||||
|
||||
`appVersion` is no longer frozen at v3.8.1; the chart tracks the published image again.
|
||||
|
||||
**Upgrading an existing install**
|
||||
|
||||
Existing `client.*`, `api.*` and `server.*` overrides are still read and merged over
|
||||
the `app.*` defaults, so most values files upgrade without edits. Two cases fail
|
||||
deliberately rather than deploying something wrong:
|
||||
|
||||
- `client.image` set — that image is no longer published, so the chart stops instead
|
||||
of leaving you with an ImagePullBackOff. Remove the `client:` block.
|
||||
- `client.ingress.host` and `api.ingress.host` set to *different* hosts — one origin
|
||||
now serves both, so the chart cannot honour a split. Set a single `app.ingress.host`.
|
||||
|
||||
Migrate overrides to `app.*` when convenient; the legacy blocks are transitional.
|
||||
|
||||
**One ingress, one host**
|
||||
|
||||
If you previously served the client and API on separate hostnames, external clients
|
||||
now use the single `app.ingress.host`. Update any bookmarks, uptime checks, or DNS
|
||||
records that pointed at the old API hostname.
|
||||
|
||||
@@ -1,41 +1,18 @@
|
||||
{{- /*
|
||||
Back-compat shim for the all-in-one image migration.
|
||||
Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that
|
||||
still pass a legacy `server:` block keep working — this returns the effective API values with any
|
||||
legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set).
|
||||
|
||||
Chart 0.2.x deployed a separate client and API, configured through `client.*` and
|
||||
`api.*` (and, before that, `server.*`). Checkmate now ships one image that serves
|
||||
both, so those collapse into a single `app.*` block.
|
||||
|
||||
Existing installs keep working: any legacy block is merged over the `app.*`
|
||||
defaults, in the order client -> api -> server, so a value set in an older block
|
||||
still wins. Only `image`/`tag` are deliberately NOT inherited from `client.*` —
|
||||
that repo (checkmate-client) is no longer published, so honouring it would pin an
|
||||
install to an image that cannot be pulled.
|
||||
|
||||
New installs have no legacy keys and this is a no-op deepCopy of `.Values.app`.
|
||||
Consume it as:
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
*/}}
|
||||
{{- define "checkmate.app" -}}
|
||||
{{- $app := deepCopy .Values.app -}}
|
||||
{{- with .Values.client -}}
|
||||
{{- $legacy := omit (deepCopy .) "image" "tag" "port" -}}
|
||||
{{- $app = mergeOverwrite $app $legacy -}}
|
||||
{{- end -}}
|
||||
{{- with .Values.api -}}
|
||||
{{- $app = mergeOverwrite $app (deepCopy .) -}}
|
||||
{{- end -}}
|
||||
{{- with .Values.server -}}
|
||||
{{- $app = mergeOverwrite $app (deepCopy .) -}}
|
||||
{{- end -}}
|
||||
{{- $app | toYaml -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- /*
|
||||
Deprecated alias kept so any out-of-tree template referencing `checkmate.api`
|
||||
still resolves. Prefer `checkmate.app`.
|
||||
New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as:
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
then reference `$api.*` instead of `.Values.api.*`.
|
||||
*/}}
|
||||
{{- define "checkmate.api" -}}
|
||||
{{- include "checkmate.app" . -}}
|
||||
{{- $api := deepCopy .Values.api -}}
|
||||
{{- with .Values.server -}}
|
||||
{{- $api = mergeOverwrite $api (deepCopy .) -}}
|
||||
{{- end -}}
|
||||
{{- $api | toYaml -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- /*
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-api
|
||||
spec:
|
||||
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-api
|
||||
spec:
|
||||
{{- with $api.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $api.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: api
|
||||
image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }}
|
||||
imagePullPolicy: {{ $api.imagePullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ $api.port }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: checkmate-secrets
|
||||
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
|
||||
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is
|
||||
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: http
|
||||
initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ $api.probes.liveness.periodSeconds }}
|
||||
timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }}
|
||||
failureThreshold: {{ $api.probes.liveness.failureThreshold }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: http
|
||||
initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ $api.probes.readiness.periodSeconds }}
|
||||
timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }}
|
||||
failureThreshold: {{ $api.probes.readiness.failureThreshold }}
|
||||
{{- with $api.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
- name: QUEUE_MODE
|
||||
value: "primary"
|
||||
# Derived: when a worker tier is deployed the API only schedules (false); otherwise it is
|
||||
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
|
||||
- name: QUEUE_PRIMARY_PROCESSES
|
||||
value: {{ not .Values.worker.enabled | quote }}
|
||||
@@ -0,0 +1,39 @@
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
{{- if $api.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: checkmate-api-ingress
|
||||
{{- if $api.ingress.annotations }}
|
||||
annotations:
|
||||
{{- range $key, $value := $api.ingress.annotations }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{/*#annotations:
|
||||
#nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
#nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }},https://{{ .Values.client.ingress.host }}"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
|
||||
spec:
|
||||
ingressClassName: {{ $api.ingress.className }}
|
||||
{{- if $api.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ $api.ingress.host }}
|
||||
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ $api.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /api/v1
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkmate-api
|
||||
port:
|
||||
number: {{ $api.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-api
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-api
|
||||
ports:
|
||||
- port: {{ $api.port }}
|
||||
@@ -1,71 +0,0 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate
|
||||
spec:
|
||||
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate
|
||||
spec:
|
||||
{{- with $app.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $app.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: checkmate
|
||||
image: {{ include "checkmate.image" (dict "image" $app.image "tag" $app.tag "root" $) }}
|
||||
imagePullPolicy: {{ $app.imagePullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ $app.port }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: checkmate-secrets
|
||||
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
|
||||
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the app is
|
||||
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: http
|
||||
initialDelaySeconds: {{ $app.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ $app.probes.liveness.periodSeconds }}
|
||||
timeoutSeconds: {{ $app.probes.liveness.timeoutSeconds }}
|
||||
failureThreshold: {{ $app.probes.liveness.failureThreshold }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: http
|
||||
initialDelaySeconds: {{ $app.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ $app.probes.readiness.periodSeconds }}
|
||||
timeoutSeconds: {{ $app.probes.readiness.timeoutSeconds }}
|
||||
failureThreshold: {{ $app.probes.readiness.failureThreshold }}
|
||||
{{- with $app.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
# The client is served from this same origin, so CLIENT_HOST defaults to
|
||||
# the app's own ingress URL. `env` takes precedence over `envFrom`, so an
|
||||
# explicit secrets.CLIENT_HOST is honoured by omitting this entry rather
|
||||
# than emitting a value that would silently win over it.
|
||||
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ $app.protocol }}://{{ $app.ingress.host }}"
|
||||
{{- end }}
|
||||
- name: QUEUE_MODE
|
||||
value: "primary"
|
||||
# Derived: when a worker tier is deployed the app only schedules (false); otherwise it is
|
||||
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
|
||||
- name: QUEUE_PRIMARY_PROCESSES
|
||||
value: {{ not .Values.worker.enabled | quote }}
|
||||
@@ -1,35 +0,0 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
{{- if $app.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: checkmate-ingress
|
||||
{{- if $app.ingress.annotations }}
|
||||
annotations:
|
||||
{{- range $key, $value := $app.ingress.annotations }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ $app.ingress.className }}
|
||||
{{- if $app.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ $app.ingress.host }}
|
||||
secretName: {{ default (printf "%s-tls" .Release.Name) $app.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ $app.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
# The application serves both the client and /api/v1 from one origin, so
|
||||
# a single root rule replaces the separate client and API ingresses. No
|
||||
# CORS annotations are needed now that the browser is same-origin.
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkmate
|
||||
port:
|
||||
number: {{ $app.port }}
|
||||
{{- end }}
|
||||
@@ -1,10 +0,0 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate
|
||||
ports:
|
||||
- port: {{ $app.port }}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-client
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-client
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-client
|
||||
spec:
|
||||
{{- with .Values.client.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.client.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: client
|
||||
image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }}
|
||||
imagePullPolicy: {{ .Values.client.imagePullPolicy }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.client.port }}
|
||||
env:
|
||||
- name: UPTIME_APP_API_BASE_URL
|
||||
value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1"
|
||||
- name: UPTIME_APP_CLIENT_HOST
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
volumeMounts:
|
||||
- name: config-volume
|
||||
mountPath: /etc/nginx/conf.d/default.conf
|
||||
subPath: default.conf
|
||||
{{- with .Values.client.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap:
|
||||
name: checkmate-client-nginx-cm
|
||||
@@ -0,0 +1,31 @@
|
||||
{{- if .Values.client.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: checkmate-client-ingress
|
||||
{{- if .Values.client.ingress.annotations }}
|
||||
annotations:
|
||||
{{- range $key, $value := .Values.client.ingress.annotations }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.client.ingress.className }}
|
||||
{{- if .Values.client.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.client.ingress.host }}
|
||||
secretName: {{ default (printf "%s-client-tls" .Release.Name) .Values.client.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.client.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkmate-client
|
||||
port:
|
||||
number: {{ .Values.client.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,42 @@
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: checkmate-client-nginx-cm
|
||||
data:
|
||||
default.conf: |
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name checkmate-demo.bluewavelabs.ca;
|
||||
server_tokens off;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# location /api/ {
|
||||
# proxy_pass http://{{ $api.ingress.host }}:5000/api/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
|
||||
location /api-docs/ {
|
||||
proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-client
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-client
|
||||
ports:
|
||||
- port: {{ .Values.client.port }}
|
||||
@@ -1,45 +1,49 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
{{- if eq $app.ingress.host "change_me" }}
|
||||
{{- fail "app.ingress.host must be overridden and not set to 'change_me'" }}
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
{{- if eq .Values.client.ingress.host "change_me" }}
|
||||
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $protocol := $app.protocol }}
|
||||
{{- if not (or (eq $protocol "http") (eq $protocol "https")) }}
|
||||
{{- fail "app.protocol must be either 'http' or 'https'" }}
|
||||
{{- if eq $api.ingress.host "change_me" }}
|
||||
{{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $apiProtocol := $api.protocol }}
|
||||
{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }}
|
||||
{{- fail "api.protocol must be either 'http' or 'https'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $clientProtocol := .Values.client.protocol }}
|
||||
{{- if not (or (eq $clientProtocol "http") (eq $clientProtocol "https")) }}
|
||||
{{- fail "client.protocol must be either 'http' or 'https'" }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Enforce protocol when TLS is enabled to avoid mixed-content */}}
|
||||
{{- if and $app.ingress.tls.enabled (ne $protocol "https") }}
|
||||
{{- fail "app.ingress.tls.enabled is true but app.protocol is not 'https'. Set app.protocol: https to avoid mixed content." }}
|
||||
{{- if and .Values.client.ingress.tls.enabled (ne $clientProtocol "https") }}
|
||||
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }}
|
||||
{{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* If client runs on https, API must also be https to avoid mixed content */}}
|
||||
{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }}
|
||||
{{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
|
||||
{{- $ann := $app.ingress.annotations | default dict }}
|
||||
{{- $hasIssuer := or (hasKey $ann "cert-manager.io/cluster-issuer") (hasKey $ann "cert-manager.io/issuer") }}
|
||||
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
|
||||
{{- $aAnn := $api.ingress.annotations | default dict }}
|
||||
|
||||
{{- if and $app.ingress.tls.enabled (not $hasIssuer) }}
|
||||
{{- fail "app.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
|
||||
{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }}
|
||||
|
||||
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
|
||||
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Upgrade guard. The client and API are one service now, so a values file that still
|
||||
points the two at different hosts cannot be honoured — fail loudly rather than
|
||||
silently dropping one of them.
|
||||
*/}}
|
||||
{{- if and .Values.client .Values.api }}
|
||||
{{- $clientHost := (.Values.client.ingress | default dict).host }}
|
||||
{{- $apiHost := (.Values.api.ingress | default dict).host }}
|
||||
{{- if and $clientHost $apiHost (ne $clientHost $apiHost) }}
|
||||
{{- fail (printf "client.ingress.host (%s) and api.ingress.host (%s) differ, but Checkmate now serves the client and the API from one origin. Set a single app.ingress.host instead." $clientHost $apiHost) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
The client image is no longer published. Point this out rather than letting the
|
||||
install fail later on an ImagePullBackOff nobody can action.
|
||||
*/}}
|
||||
{{- if and .Values.client .Values.client.image }}
|
||||
{{- fail "client.image is set, but the separate client image is no longer published. Remove the client block; the application image in app.image now serves the client." }}
|
||||
{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }}
|
||||
{{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
|
||||
@@ -47,4 +51,4 @@
|
||||
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Secret name can be omitted; we default to <release>-tls in templates */}}
|
||||
{{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
|
||||
@@ -1,4 +1,4 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
{{- if .Values.worker.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -27,8 +27,8 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: worker
|
||||
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $app.image) "tag" (.Values.worker.tag | default $app.tag) "root" $) }}
|
||||
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $app.imagePullPolicy }}
|
||||
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }}
|
||||
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }}
|
||||
ports:
|
||||
- name: health
|
||||
containerPort: {{ .Values.worker.healthPort }}
|
||||
@@ -36,13 +36,9 @@ spec:
|
||||
- secretRef:
|
||||
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
|
||||
env:
|
||||
# validateEnv requires a URL-shaped CLIENT_HOST, so mirror the app's default
|
||||
# here. Omitted when secrets.CLIENT_HOST is set, since `env` would otherwise
|
||||
# take precedence over the envFrom value and silently discard the override.
|
||||
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
|
||||
# Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ $app.protocol }}://{{ $app.ingress.host }}"
|
||||
{{- end }}
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
- name: QUEUE_MODE
|
||||
value: "worker"
|
||||
- name: HEALTH_PORT
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
# Checkmate is published as a single all-in-one application image: the API server
|
||||
# also serves the React client, so one deployment and one ingress cover both.
|
||||
# The `client:` and `api:` blocks from chart 0.2.x are still read for back-compat
|
||||
# (see templates/_helpers.tpl) — `app.*` is the block to set on new installs.
|
||||
app:
|
||||
image: ghcr.io/bluewave-labs/checkmate
|
||||
client:
|
||||
image: ghcr.io/bluewave-labs/checkmate-client
|
||||
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
|
||||
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||
port: 80
|
||||
protocol: http
|
||||
ingress:
|
||||
enabled: true
|
||||
host: change_me
|
||||
className: nginx
|
||||
annotations: {}
|
||||
# Example annotations for cert-manager:
|
||||
# annotations:
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: false
|
||||
# secretName: {{ .Release.Name }}-client-tls # Optional; defaults to <release>-client-tls if omitted
|
||||
# Note: when enabling TLS, also set client.protocol: https and add
|
||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||
|
||||
api:
|
||||
image: ghcr.io/bluewave-labs/checkmate-backend
|
||||
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
|
||||
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||
port: 52345
|
||||
protocol: http
|
||||
resources: {}
|
||||
affinity: {}
|
||||
tolerations: {}
|
||||
# Liveness/readiness probes hit GET /api/v1/health on the app port (proves the HTTP server is up).
|
||||
# Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
|
||||
probes:
|
||||
liveness:
|
||||
initialDelaySeconds: 10
|
||||
@@ -33,27 +47,25 @@ app:
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: false
|
||||
# secretName: {{ .Release.Name }}-tls # Optional; defaults to <release>-tls if omitted
|
||||
# Note: when enabling TLS, also set app.protocol: https and add
|
||||
# secretName: {{ .Release.Name }}-api-tls # Optional; defaults to <release>-api-tls if omitted
|
||||
# Note: when enabling TLS, also set api.protocol: https and add
|
||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||
|
||||
# Upstream MongoDB. The former ghcr.io/bluewave-labs/checkmate-mongo image is no
|
||||
# longer published; this matches the mongo version in docker/docker-compose.yaml.
|
||||
mongodb:
|
||||
image: mongo
|
||||
tag: "8.0"
|
||||
imagePullPolicy: IfNotPresent
|
||||
image: ghcr.io/bluewave-labs/checkmate-mongo
|
||||
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin
|
||||
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||
port: 27017
|
||||
|
||||
worker:
|
||||
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all
|
||||
# jobs and the app stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
|
||||
# app is all-in-one and processes jobs itself.
|
||||
# jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
|
||||
# API is all-in-one and processes jobs itself.
|
||||
enabled: false
|
||||
image: "" # defaults to app.image when empty, same as the application image
|
||||
tag: "" # defaults to app.tag (-> Chart.appVersion) when empty
|
||||
imagePullPolicy: "" # defaults to app.imagePullPolicy when empty
|
||||
image: "" # defaults to api.image when empty, same as API image
|
||||
tag: "" # defaults to api.tag (-> Chart.appVersion) when empty
|
||||
imagePullPolicy: "" # defaults to api.imagePullPolicy when empty
|
||||
replicas: 1 # used only when autoscaling.enabled = false
|
||||
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics
|
||||
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain
|
||||
@@ -85,8 +97,7 @@ worker:
|
||||
secrets:
|
||||
JWT_SECRET: change_me
|
||||
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
|
||||
# CLIENT_HOST is derived from app.protocol + app.ingress.host by the deployment;
|
||||
# set it here only to override that (e.g. when fronting the app with another proxy).
|
||||
CLIENT_HOST: change_me
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: info
|
||||
TOKEN_TTL: 99d
|
||||
|
||||
Generated
+10
@@ -11,6 +11,7 @@
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@hello-pangea/dnd": "^18.0.0",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@mui/lab": "7.0.0",
|
||||
@@ -950,6 +951,15 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
||||
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"license": "BSD-3-Clause"
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@hello-pangea/dnd": "^18.0.0",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@mui/lab": "7.0.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export type ActionMenuItem = {
|
||||
id: number | string;
|
||||
@@ -12,6 +13,7 @@ export type ActionMenuItem = {
|
||||
};
|
||||
|
||||
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
||||
const theme = useTheme();
|
||||
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
@@ -27,7 +29,14 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconButton onClick={handleClick}>
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.controlHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Settings
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useTheme } from "@mui/material/styles";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaQuery } from "@mui/material";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
interface MonitorTimeFrameHeaderProps {
|
||||
isLoading?: boolean;
|
||||
hasDateRange?: boolean;
|
||||
dateRange: string;
|
||||
setDateRange: (dateRange: string) => void;
|
||||
dateRange: DateRange;
|
||||
setDateRange: (dateRange: DateRange) => void;
|
||||
}
|
||||
|
||||
export const HeaderTimeRange = ({
|
||||
@@ -25,7 +26,7 @@ export const HeaderTimeRange = ({
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const handleChange = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
newValue: string | null
|
||||
newValue: DateRange | null
|
||||
) => {
|
||||
if (newValue !== null) {
|
||||
setDateRange(newValue);
|
||||
|
||||
@@ -10,8 +10,10 @@ import { useTheme } from "@mui/material/styles";
|
||||
type BaseChartProps = React.PropsWithChildren<{
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
rightTitle?: string;
|
||||
width?: number | string;
|
||||
maxWidth?: number | string;
|
||||
flexBasis?: number | string;
|
||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
@@ -19,8 +21,10 @@ type BaseChartProps = React.PropsWithChildren<{
|
||||
export const BaseChart = ({
|
||||
children,
|
||||
title,
|
||||
rightTitle,
|
||||
width = "100%",
|
||||
maxWidth = "100%",
|
||||
flexBasis = "0%",
|
||||
padding,
|
||||
onClick,
|
||||
}: BaseChartProps) => {
|
||||
@@ -32,6 +36,7 @@ export const BaseChart = ({
|
||||
padding: padding ?? theme.spacing(LAYOUT.MD),
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexBasis,
|
||||
width: width,
|
||||
maxWidth: maxWidth,
|
||||
...(onClick && {
|
||||
@@ -44,13 +49,27 @@ export const BaseChart = ({
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
flex={1}
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
sx={{ overflowWrap: "anywhere" }}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
sx={{ overflowWrap: "anywhere" }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{rightTitle && (
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
sx={{ overflowWrap: "anywhere" }}
|
||||
>
|
||||
{rightTitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Box flex={1}>{children}</Box>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
|
||||
@@ -83,12 +83,17 @@ export const Breadcrumb = ({
|
||||
const basePage = segments[0] || t("common.breadcrumbs.home");
|
||||
|
||||
const secondSegment = segments[1];
|
||||
const thirdSegment = segments[2];
|
||||
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
||||
const isDetailsPage = secondSegment && isId(secondSegment);
|
||||
const hasSubPage = isActionPage || isDetailsPage;
|
||||
// page/section/:id routes such as docker/host/:monitorId
|
||||
const isSectionDetailsPage = Boolean(
|
||||
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
|
||||
);
|
||||
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
|
||||
|
||||
const getSubPageLabel = (): string => {
|
||||
if (isActionPage) {
|
||||
if (isActionPage || isSectionDetailsPage) {
|
||||
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
||||
}
|
||||
return t("common.breadcrumbs.details");
|
||||
|
||||
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
||||
() => ({
|
||||
circumference: 2 * Math.PI * radius,
|
||||
totalSize: radius * 2 + strokeWidth * 2,
|
||||
strokeLength: (progress / 100) * (2 * Math.PI * radius),
|
||||
strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius),
|
||||
}),
|
||||
[radius, strokeWidth, progress]
|
||||
[radius, strokeWidth, progressWithinRange]
|
||||
);
|
||||
|
||||
const [offset, setOffset] = useState(circumference);
|
||||
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [progress, circumference, strokeLength]);
|
||||
}, [circumference, strokeLength]);
|
||||
|
||||
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
|
||||
|
||||
@@ -123,6 +123,7 @@ export const DetailGauge = ({
|
||||
lowerLabel,
|
||||
lowerValue,
|
||||
maxWidth = 225,
|
||||
flexBasis = "0%",
|
||||
}: {
|
||||
title: string;
|
||||
progress: number;
|
||||
@@ -131,6 +132,7 @@ export const DetailGauge = ({
|
||||
lowerLabel?: string;
|
||||
lowerValue?: string | number;
|
||||
maxWidth?: number;
|
||||
flexBasis?: number | string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
@@ -138,6 +140,7 @@ export const DetailGauge = ({
|
||||
icon={null}
|
||||
title={title}
|
||||
maxWidth={maxWidth}
|
||||
flexBasis={flexBasis}
|
||||
>
|
||||
<Stack
|
||||
alignItems={"center"}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface IconProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
|
||||
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
|
||||
return (
|
||||
<Icon
|
||||
size={size}
|
||||
|
||||
@@ -37,8 +37,8 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
backgroundColor: theme.palette.error.main,
|
||||
color: theme.palette.error.contrastText,
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
color: "#FFFFFF",
|
||||
px: theme.spacing(8),
|
||||
py: theme.spacing(4),
|
||||
transition: "top 1s ease-in-out",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
||||
import { CONTROL_HEIGHT, ICON_CONTROL_WIDTH } from "@/Utils/Theme/constants";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@@ -109,7 +110,7 @@ export const PageHeader = ({
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.55,
|
||||
maxWidth: 640,
|
||||
maxWidth: "108ch",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
@@ -141,8 +142,8 @@ export const PageHeader = ({
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: 32,
|
||||
width: ICON_CONTROL_WIDTH,
|
||||
height: CONTROL_HEIGHT,
|
||||
color: theme.palette.text.secondary,
|
||||
borderRight:
|
||||
idx < linkItems.length - 1
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Stack, useTheme } from "@mui/material";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface SkeletonCardProps {
|
||||
width?: number | string | { xs?: number | string; md?: number | string };
|
||||
@@ -44,7 +45,7 @@ export const SkeletonCard = ({
|
||||
|
||||
const blockStyle = {
|
||||
width: 55,
|
||||
height: 34,
|
||||
height: CONTROL_HEIGHT,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: theme.palette.secondary.main,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { lighten } from "@mui/material/styles";
|
||||
import { darken, lighten } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
|
||||
@@ -11,17 +11,18 @@ import type { SxProps } from "@mui/material";
|
||||
type GradientBox = React.PropsWithChildren<{
|
||||
palette?: PaletteKey;
|
||||
sx?: SxProps;
|
||||
interactive?: boolean;
|
||||
}>;
|
||||
|
||||
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
||||
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const paper = theme.palette.background.paper;
|
||||
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
|
||||
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
|
||||
const paperEnd = paper;
|
||||
const bg = palette
|
||||
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
|
||||
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
|
||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||
|
||||
return (
|
||||
@@ -30,6 +31,12 @@ export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
||||
background: bg,
|
||||
...(interactive && {
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
|
||||
},
|
||||
}),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
@@ -62,10 +69,8 @@ export const StatBox = ({
|
||||
return (
|
||||
<GradientBox
|
||||
palette={palette}
|
||||
sx={{
|
||||
...(sx as object),
|
||||
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
|
||||
}}
|
||||
interactive={Boolean(onClick)}
|
||||
sx={{ ...(sx as object) }}
|
||||
>
|
||||
<Stack onClick={onClick}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SxProps } from "@mui/material";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
type StatusBoxProps = React.PropsWithChildren<{
|
||||
children: React.ReactNode;
|
||||
@@ -33,9 +33,15 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
|
||||
bottom={0}
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
// Deliberately far fainter than `divider` - this is texture
|
||||
// behind the number, not a rule, and it must never compete
|
||||
// with the box's own border. Note alpha() *replaces* the
|
||||
// alpha channel, so this relies on `divider` being an opaque
|
||||
// hex; if it ever goes translucent this gets stronger, not
|
||||
// weaker.
|
||||
backgroundImage: `
|
||||
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
|
||||
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { BaseBox } from "@/Components/design-elements";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Stack from "@mui/material/Stack";
|
||||
|
||||
import type { MonitorStatus } from "@/Types/Monitor";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
|
||||
import {
|
||||
getDockerStatePalette,
|
||||
getStatusPalette,
|
||||
getValuePalette,
|
||||
} from "@/Utils/MonitorUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainerState } from "@/Types/Check";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
|
||||
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
||||
export type ValueType = (typeof ValueTypes)[number];
|
||||
@@ -110,3 +117,36 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const DockerStateLabel = ({
|
||||
state,
|
||||
name,
|
||||
image,
|
||||
}: {
|
||||
state: DockerContainerState;
|
||||
name: string;
|
||||
image: string;
|
||||
}) => {
|
||||
const palette = getDockerStatePalette(state);
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={SPACING.LG}
|
||||
alignItems={"center"}
|
||||
>
|
||||
<Box
|
||||
width={7}
|
||||
height={7}
|
||||
bgcolor={theme.palette[palette].light}
|
||||
borderRadius="50%"
|
||||
marginRight="5px"
|
||||
/>
|
||||
<Stack>
|
||||
<Typography>{name}</Typography>
|
||||
<Typography variant="body2">{image}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -52,6 +52,12 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
|
||||
emptyViewText?: string;
|
||||
emptyViewPositive?: boolean;
|
||||
getRowSx?: (row: T) => SxProps<Theme>;
|
||||
/**
|
||||
* Marks a row selected so it gets the shared selected + hover treatment.
|
||||
* Desktop table only - the small-screen card branch does not style
|
||||
* selection yet, same as `getRowSx`.
|
||||
*/
|
||||
isRowSelected?: (row: T) => boolean;
|
||||
};
|
||||
|
||||
export function DataTable<
|
||||
@@ -70,6 +76,7 @@ export function DataTable<
|
||||
emptyViewText,
|
||||
emptyViewPositive,
|
||||
getRowSx,
|
||||
isRowSelected,
|
||||
}: DataTableProps<T>) {
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
||||
@@ -79,6 +86,7 @@ export function DataTable<
|
||||
};
|
||||
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isInteractive = Boolean(onRowClick) || expandableRows;
|
||||
|
||||
if (data.length === 0 || headers.length === 0) {
|
||||
return (
|
||||
@@ -108,6 +116,11 @@ export function DataTable<
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(LAYOUT.XS),
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
...(onRowClick && {
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.rowHover,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
key={key}
|
||||
>
|
||||
@@ -191,7 +204,7 @@ export function DataTable<
|
||||
"& :is(th)": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
||||
@@ -207,6 +220,17 @@ export function DataTable<
|
||||
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
|
||||
borderBottom: "none",
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root.is-selected .MuiTableCell-root": {
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root":
|
||||
{
|
||||
backgroundColor: theme.palette.action.rowHover,
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root.is-selected.is-clickable:hover .MuiTableCell-root":
|
||||
{
|
||||
backgroundColor: theme.palette.action.selectedHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
@@ -231,8 +255,14 @@ export function DataTable<
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<TableRow
|
||||
className={[
|
||||
isInteractive ? "is-clickable" : "",
|
||||
isRowSelected?.(row) ? "is-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
sx={{
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
cursor: isInteractive ? "pointer" : "default",
|
||||
...(getRowSx?.(row) as object),
|
||||
}}
|
||||
onClick={() => {
|
||||
@@ -292,6 +322,12 @@ interface TablePaginationActionsProps {
|
||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
||||
}
|
||||
|
||||
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.controlHover,
|
||||
},
|
||||
});
|
||||
|
||||
function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
const theme = useTheme();
|
||||
const { count, page, rowsPerPage, onPageChange } = props;
|
||||
@@ -321,6 +357,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
onClick={handleFirstPageButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsRight
|
||||
@@ -338,6 +375,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="previous page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronRight
|
||||
@@ -355,6 +393,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="next page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronLeft
|
||||
@@ -372,6 +411,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
onClick={handleLastPageButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="last page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsLeft
|
||||
@@ -419,6 +459,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
||||
onClick={handleFirstPageButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsRight
|
||||
@@ -436,6 +477,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="previous page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronRight
|
||||
@@ -453,6 +495,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={hasMore === false}
|
||||
aria-label="next page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronLeft
|
||||
@@ -470,6 +513,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
||||
<IconButton
|
||||
disabled={hasMore === false}
|
||||
aria-label="next page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
<Ellipsis
|
||||
size={20}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import MuiTabs from "@mui/material/Tabs";
|
||||
import type { TabsProps } from "@mui/material/Tabs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
interface CustomTabsProps extends TabsProps {}
|
||||
|
||||
export const Tabs = (props: CustomTabsProps) => {
|
||||
@@ -8,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
|
||||
return (
|
||||
<MuiTabs
|
||||
sx={{
|
||||
minHeight: 34,
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
"& .MuiTabs-indicator": {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
@@ -38,9 +40,9 @@ export const Tab = (props: CustomTabProps) => {
|
||||
iconPosition="start"
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontSize: 14,
|
||||
fontSize: typographyLevels.m,
|
||||
fontWeight: 500,
|
||||
minHeight: 34,
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
padding: theme.spacing(1, 0),
|
||||
paddingBottom: 0,
|
||||
minWidth: "auto",
|
||||
@@ -48,7 +50,6 @@ export const Tab = (props: CustomTabProps) => {
|
||||
color: theme.palette.text.secondary,
|
||||
"&.Mui-selected": {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
},
|
||||
"&:hover": {
|
||||
color: theme.palette.text.secondary,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import type { ButtonProps } from "@mui/material/Button";
|
||||
import { useTheme, darken } from "@mui/material/styles";
|
||||
import { HOVER } from "@/Utils/Theme/constants";
|
||||
import { CONTROL_HEIGHT, HOVER } from "@/Utils/Theme/constants";
|
||||
|
||||
const PALETTE_COLORS = [
|
||||
"primary",
|
||||
@@ -43,7 +43,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
||||
{...props}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
height: 34,
|
||||
height: CONTROL_HEIGHT,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 400,
|
||||
borderRadius: 2,
|
||||
whiteSpace: "nowrap",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface ColorPickerProps extends MuiColorInputProps {
|
||||
fieldLabel?: string;
|
||||
@@ -18,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: 34,
|
||||
height: CONTROL_HEIGHT,
|
||||
fontSize: typographyLevels.base,
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FieldLabel } from "./FieldLabel";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -55,7 +56,7 @@ export const DatePickerComponent = ({
|
||||
flexDirection: "row-reverse",
|
||||
},
|
||||
"& input": {
|
||||
minHeight: 34,
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
p: 0,
|
||||
pl: theme.spacing(3),
|
||||
pr: theme.spacing(5),
|
||||
@@ -66,7 +67,9 @@ export const DatePickerComponent = ({
|
||||
},
|
||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||
{
|
||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
||||
borderColor: error
|
||||
? theme.palette.error.main
|
||||
: theme.palette.text.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -71,7 +72,7 @@ const SelectInputInner = <T,>(
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
height: "34px",
|
||||
height: `${CONTROL_HEIGHT}px`,
|
||||
"& .MuiSelect-select": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -83,10 +84,6 @@ const SelectInputInner = <T,>(
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
...props.sx,
|
||||
}}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { CONTROL_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -22,16 +22,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
||||
width: "100%",
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: props.multiline ? "auto" : 34,
|
||||
height: props.multiline ? "auto" : CONTROL_HEIGHT,
|
||||
fontSize: typographyLevels.base,
|
||||
overflow: "hidden",
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
@@ -39,7 +33,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
||||
},
|
||||
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
|
||||
{
|
||||
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
|
||||
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`,
|
||||
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
||||
caretColor: theme.palette.text.primary,
|
||||
transition: "background-color 5000s ease-in-out 0s",
|
||||
|
||||
@@ -7,6 +7,7 @@ import Typography from "@mui/material/Typography";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -33,7 +34,7 @@ export const TimePickerComponent = ({
|
||||
sx: {
|
||||
width: "fit-content",
|
||||
"& input": {
|
||||
minHeight: 34,
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
p: 0,
|
||||
px: theme.spacing(5),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
|
||||
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
const theme = useTheme();
|
||||
@@ -20,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
sx={{
|
||||
px: 8,
|
||||
textTransform: "none",
|
||||
height: 34,
|
||||
height: CONTROL_HEIGHT,
|
||||
fontWeight: 400,
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -34,7 +35,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: selectedBg,
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 500,
|
||||
"&:hover": {
|
||||
backgroundColor: selectedHoverBg,
|
||||
},
|
||||
|
||||
@@ -9,12 +9,11 @@ import type { Tag } from "@/Types/Tag";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
|
||||
const types = ["http", "ping", "port", "game", "grpc", "websocket"];
|
||||
const typeDisplayNames: Record<string, string> = {
|
||||
http: "HTTP",
|
||||
ping: "Ping",
|
||||
port: "Port",
|
||||
docker: "Docker",
|
||||
game: "Game",
|
||||
grpc: "gRPC",
|
||||
websocket: "WebSocket",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
export const computeYAxisCap = (values: number[]): number | undefined => {
|
||||
const sorted = values.slice().sort((a, b) => a - b);
|
||||
if (sorted.length < 2) return undefined;
|
||||
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
|
||||
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
|
||||
};
|
||||
|
||||
export const PHASE_KEYS = [
|
||||
"avgDns",
|
||||
"avgTcp",
|
||||
"avgTls",
|
||||
"avgRequest",
|
||||
"avgFirstByte",
|
||||
"avgDownload",
|
||||
] as const;
|
||||
|
||||
export const PHASE_COLOR_KEYS = {
|
||||
avgDns: "dns",
|
||||
avgTcp: "tcp",
|
||||
avgTls: "tls",
|
||||
avgRequest: "request",
|
||||
avgFirstByte: "firstByte",
|
||||
avgDownload: "download",
|
||||
} as const;
|
||||
|
||||
// i18n keys, resolved with t() at render
|
||||
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
|
||||
avgDns: "common.charts.phases.dns",
|
||||
avgTcp: "common.charts.phases.tcp",
|
||||
avgTls: "common.charts.phases.tls",
|
||||
avgRequest: "common.charts.phases.request",
|
||||
avgFirstByte: "common.charts.phases.firstByte",
|
||||
avgDownload: "common.charts.phases.download",
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
export const computeYAxisCap = (values: number[]): number | undefined => {
|
||||
const sorted = values.slice().sort((a, b) => a - b);
|
||||
if (sorted.length < 2) return undefined;
|
||||
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
|
||||
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
|
||||
};
|
||||
|
||||
export const PHASE_KEYS = [
|
||||
"avgDns",
|
||||
"avgTcp",
|
||||
"avgTls",
|
||||
"avgRequest",
|
||||
"avgFirstByte",
|
||||
"avgDownload",
|
||||
] as const;
|
||||
|
||||
export const PHASE_COLOR_KEYS = {
|
||||
avgDns: "dns",
|
||||
avgTcp: "tcp",
|
||||
avgTls: "tls",
|
||||
avgRequest: "request",
|
||||
avgFirstByte: "firstByte",
|
||||
avgDownload: "download",
|
||||
} as const;
|
||||
|
||||
// i18n keys, resolved with t() at render
|
||||
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
|
||||
avgDns: "common.charts.phases.dns",
|
||||
avgTcp: "common.charts.phases.tcp",
|
||||
avgTls: "common.charts.phases.tls",
|
||||
avgRequest: "common.charts.phases.request",
|
||||
avgFirstByte: "common.charts.phases.firstByte",
|
||||
avgDownload: "common.charts.phases.download",
|
||||
};
|
||||
|
||||
export const AREA_COLORS = [
|
||||
// Blues
|
||||
"#3182bd", // Deep blue
|
||||
"#6baed6", // Medium blue
|
||||
"#9ecae1", // Light blue
|
||||
|
||||
// Greens
|
||||
"#74c476", // Soft green
|
||||
"#a1d99b", // Light green
|
||||
"#c7e9c0", // Pale green
|
||||
|
||||
// Oranges
|
||||
"#fdae6b", // Warm orange
|
||||
"#fdd0a2", // Light orange
|
||||
"#feedde", // Pale orange
|
||||
|
||||
// Purples
|
||||
"#9467bd", // Lavender
|
||||
"#a55194", // Deep magenta
|
||||
"#c994c7", // Soft magenta
|
||||
|
||||
// Reds
|
||||
"#ff9896", // Soft red
|
||||
"#de2d26", // Deep red
|
||||
"#fc9272", // Medium red
|
||||
|
||||
// Cyans/Teals
|
||||
"#17becf", // Cyan
|
||||
"#7fcdbb", // Teal
|
||||
"#a1dab4", // Light teal
|
||||
|
||||
// Yellows
|
||||
"#fec44f", // Mustard
|
||||
"#fee391", // Light yellow
|
||||
"#ffffd4", // Pale yellow
|
||||
|
||||
// Additional colors
|
||||
"#e377c2", // Soft pink
|
||||
"#bcbd22", // Olive
|
||||
"#2ca02c", // Vibrant green
|
||||
];
|
||||
|
||||
export const createGradient = ({
|
||||
id,
|
||||
startColor,
|
||||
endColor,
|
||||
startOpacity = 0.8,
|
||||
endOpacity = 0,
|
||||
direction = "vertical",
|
||||
}: {
|
||||
id: string;
|
||||
startColor: string;
|
||||
endColor: string;
|
||||
startOpacity?: number;
|
||||
endOpacity?: number;
|
||||
direction?: "vertical" | "horizontal";
|
||||
}) => (
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id}
|
||||
x1={direction === "vertical" ? "0" : "0"}
|
||||
y1={direction === "vertical" ? "0" : "0"}
|
||||
x2={direction === "vertical" ? "0" : "1"}
|
||||
y2={direction === "vertical" ? "1" : "0"}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={startColor}
|
||||
stopOpacity={startOpacity}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={endColor}
|
||||
stopOpacity={endOpacity}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BaseChart } from "@/Components/design-elements";
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||
|
||||
// Types
|
||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { Fragment, useId } from "react";
|
||||
|
||||
// Utils
|
||||
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||
interface HistogramDockerContainerProps {
|
||||
title: string;
|
||||
rightTitle: string;
|
||||
stats: DockerContainerStats;
|
||||
dataKey: string;
|
||||
strokeColor: string;
|
||||
gradientStartColor: string;
|
||||
}
|
||||
|
||||
export const HistogramDockerContainer = ({
|
||||
title,
|
||||
rightTitle,
|
||||
stats,
|
||||
dataKey,
|
||||
strokeColor,
|
||||
gradientStartColor,
|
||||
}: HistogramDockerContainerProps) => {
|
||||
const uniqueId = useId();
|
||||
const theme = useTheme();
|
||||
const gradientId = `gradient-${uniqueId}`;
|
||||
|
||||
return (
|
||||
<BaseChart
|
||||
icon="null"
|
||||
title={title}
|
||||
rightTitle={rightTitle}
|
||||
>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={200}
|
||||
>
|
||||
<AreaChart data={stats.aggregate}>
|
||||
<CartesianGrid
|
||||
strokeWidth={1}
|
||||
stroke={theme.palette.divider}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<Fragment>
|
||||
{createGradient({
|
||||
id: gradientId,
|
||||
startColor: gradientStartColor,
|
||||
endColor: "transparent",
|
||||
direction: "vertical",
|
||||
})}
|
||||
<Area
|
||||
dataKey={dataKey}
|
||||
type="monotone"
|
||||
stroke={strokeColor}
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
</Fragment>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -12,85 +12,7 @@ import { XTick } from "@/Components/monitors/charts/XTick";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { HardwareCheckStats } from "@/Types/Monitor";
|
||||
|
||||
const AREA_COLORS = [
|
||||
// Blues
|
||||
"#3182bd", // Deep blue
|
||||
"#6baed6", // Medium blue
|
||||
"#9ecae1", // Light blue
|
||||
|
||||
// Greens
|
||||
"#74c476", // Soft green
|
||||
"#a1d99b", // Light green
|
||||
"#c7e9c0", // Pale green
|
||||
|
||||
// Oranges
|
||||
"#fdae6b", // Warm orange
|
||||
"#fdd0a2", // Light orange
|
||||
"#feedde", // Pale orange
|
||||
|
||||
// Purples
|
||||
"#9467bd", // Lavender
|
||||
"#a55194", // Deep magenta
|
||||
"#c994c7", // Soft magenta
|
||||
|
||||
// Reds
|
||||
"#ff9896", // Soft red
|
||||
"#de2d26", // Deep red
|
||||
"#fc9272", // Medium red
|
||||
|
||||
// Cyans/Teals
|
||||
"#17becf", // Cyan
|
||||
"#7fcdbb", // Teal
|
||||
"#a1dab4", // Light teal
|
||||
|
||||
// Yellows
|
||||
"#fec44f", // Mustard
|
||||
"#fee391", // Light yellow
|
||||
"#ffffd4", // Pale yellow
|
||||
|
||||
// Additional colors
|
||||
"#e377c2", // Soft pink
|
||||
"#bcbd22", // Olive
|
||||
"#2ca02c", // Vibrant green
|
||||
];
|
||||
|
||||
const createGradient = ({
|
||||
id,
|
||||
startColor,
|
||||
endColor,
|
||||
startOpacity = 0.8,
|
||||
endOpacity = 0,
|
||||
direction = "vertical",
|
||||
}: {
|
||||
id: string;
|
||||
startColor: string;
|
||||
endColor: string;
|
||||
startOpacity?: number;
|
||||
endOpacity?: number;
|
||||
direction?: "vertical" | "horizontal";
|
||||
}) => (
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id}
|
||||
x1={direction === "vertical" ? "0" : "0"}
|
||||
y1={direction === "vertical" ? "0" : "0"}
|
||||
x2={direction === "vertical" ? "0" : "1"}
|
||||
y2={direction === "vertical" ? "1" : "0"}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={startColor}
|
||||
stopOpacity={startOpacity}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={endColor}
|
||||
stopOpacity={endOpacity}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||
|
||||
export const HistogramInfrastructure = ({
|
||||
dateRange,
|
||||
|
||||
@@ -19,68 +19,94 @@ import {
|
||||
Users,
|
||||
Tag,
|
||||
Waypoints,
|
||||
Container,
|
||||
} from "lucide-react";
|
||||
export const getMenu = (t: Function) => {
|
||||
return [
|
||||
{
|
||||
name: t("components.sidebar.menu.uptime"),
|
||||
path: "uptime",
|
||||
icon: <Icon icon={Globe} />,
|
||||
group: t("components.sidebar.menu.groups.monitoring"),
|
||||
items: [
|
||||
{
|
||||
name: t("components.sidebar.menu.uptime"),
|
||||
path: "uptime",
|
||||
icon: <Icon icon={Globe} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.pagespeed"),
|
||||
path: "pagespeed",
|
||||
icon: <Icon icon={Gauge} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.infrastructure"),
|
||||
path: "infrastructure",
|
||||
icon: <Icon icon={Link} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.docker"),
|
||||
path: "docker",
|
||||
icon: <Icon icon={Container} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.pagespeed"),
|
||||
path: "pagespeed",
|
||||
icon: <Icon icon={Gauge} />,
|
||||
group: t("components.sidebar.menu.groups.alerting"),
|
||||
items: [
|
||||
{
|
||||
name: t("components.sidebar.menu.incidents"),
|
||||
path: "incidents",
|
||||
icon: <Icon icon={AlertTriangle} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.notifications"),
|
||||
path: "notifications",
|
||||
icon: <Icon icon={Bell} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.maintenance"),
|
||||
path: "maintenance",
|
||||
icon: <Icon icon={Wrench} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.infrastructure"),
|
||||
path: "infrastructure",
|
||||
icon: <Icon icon={Link} />,
|
||||
group: t("components.sidebar.menu.groups.reporting"),
|
||||
items: [
|
||||
{
|
||||
name: t("components.sidebar.menu.statusPages"),
|
||||
path: "status",
|
||||
icon: <Icon icon={Wifi} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.checks"),
|
||||
path: "checks",
|
||||
icon: <Icon icon={FileText} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.logs"),
|
||||
path: "logs",
|
||||
icon: <Icon icon={ScrollText} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.notifications"),
|
||||
path: "notifications",
|
||||
icon: <Icon icon={Bell} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.tags"),
|
||||
path: "tags",
|
||||
icon: <Icon icon={Tag} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.proxies"),
|
||||
path: "proxies",
|
||||
icon: <Icon icon={Waypoints} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.checks"),
|
||||
path: "checks",
|
||||
icon: <Icon icon={FileText} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.incidents"),
|
||||
path: "incidents",
|
||||
icon: <Icon icon={AlertTriangle} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.statusPages"),
|
||||
path: "status",
|
||||
icon: <Icon icon={Wifi} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.maintenance"),
|
||||
path: "maintenance",
|
||||
icon: <Icon icon={Wrench} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.logs"),
|
||||
path: "logs",
|
||||
icon: <Icon icon={ScrollText} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.settings"),
|
||||
icon: <Icon icon={Settings} />,
|
||||
path: "settings",
|
||||
group: t("components.sidebar.menu.groups.configuration"),
|
||||
items: [
|
||||
{
|
||||
name: t("components.sidebar.menu.tags"),
|
||||
path: "tags",
|
||||
icon: <Icon icon={Tag} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.proxies"),
|
||||
path: "proxies",
|
||||
icon: <Icon icon={Waypoints} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.settings"),
|
||||
path: "settings",
|
||||
icon: <Icon icon={Settings} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -28,8 +28,8 @@ export const NavItem = ({
|
||||
|
||||
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
|
||||
const buttonBgHoverColor = selected
|
||||
? theme.palette.action.selected
|
||||
: theme.palette.action.hover;
|
||||
? theme.palette.action.selectedHover
|
||||
: theme.palette.action.rowHover;
|
||||
const fontWeight = selected ? 600 : 400;
|
||||
return (
|
||||
<Tooltip
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import Backdrop from "@mui/material/Backdrop";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import List from "@mui/material/List";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useSidebar } from "@/Hooks/useSidebar.js";
|
||||
import { Logo } from "@/Components/sidebar/Logo";
|
||||
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
|
||||
@@ -29,7 +32,7 @@ export const Sidebar = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { width, transition, collapsed } = useSidebar();
|
||||
const { width, transition, transitionTiming, collapsed } = useSidebar();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const menu = getMenu(t);
|
||||
@@ -70,11 +73,11 @@ export const Sidebar = () => {
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
borderRight={`1px solid ${theme.palette.divider}`}
|
||||
width={width}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
sx={{
|
||||
touchAction: "none",
|
||||
transition: transition,
|
||||
zIndex: 1000,
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<List
|
||||
@@ -89,17 +92,52 @@ export const Sidebar = () => {
|
||||
pt={theme.spacing(LAYOUT.MD)}
|
||||
pb={theme.spacing(LAYOUT.MD)}
|
||||
/>
|
||||
{menu.map((item) => {
|
||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||
return (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
selected={selected}
|
||||
onClick={() => handleNavClick(item.path)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{menu.map((group) => (
|
||||
<Box key={group.group}>
|
||||
<Box
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
component="div"
|
||||
color={theme.palette.text.disabled}
|
||||
fontSize={typographyLevels.s}
|
||||
paddingLeft={theme.spacing(5)}
|
||||
paddingTop={theme.spacing(LAYOUT.SM)}
|
||||
paddingBottom={theme.spacing(LAYOUT.XXS)}
|
||||
whiteSpace="nowrap"
|
||||
sx={{
|
||||
opacity: collapsed ? 0 : 1,
|
||||
transition: `opacity ${transitionTiming}`,
|
||||
}}
|
||||
>
|
||||
{group.group}
|
||||
</Typography>
|
||||
<Divider
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
opacity: collapsed ? 1 : 0,
|
||||
transition: `opacity ${transitionTiming}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{group.items.map((item) => {
|
||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||
return (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
selected={selected}
|
||||
onClick={() => handleNavClick(item.path)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
<StarPrompt />
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ type TableName =
|
||||
| "maintenance"
|
||||
| "infrastructure"
|
||||
| "logs"
|
||||
| "pagespeed";
|
||||
| "pagespeed"
|
||||
| "docker";
|
||||
|
||||
interface TableState {
|
||||
rowsPerPage: number;
|
||||
@@ -24,6 +25,7 @@ interface UIState {
|
||||
team: TableState;
|
||||
maintenance: TableState;
|
||||
infrastructure: TableState;
|
||||
docker: TableState;
|
||||
logs: TableState;
|
||||
sidebar: SidebarState;
|
||||
mode: ThemeMode;
|
||||
@@ -56,6 +58,9 @@ const initialState: UIState = {
|
||||
infrastructure: {
|
||||
rowsPerPage: 5,
|
||||
},
|
||||
docker: {
|
||||
rowsPerPage: 5,
|
||||
},
|
||||
logs: {
|
||||
rowsPerPage: 15,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,8 @@ const SIDEBAR_WIDTH_VAR = 250;
|
||||
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
|
||||
|
||||
// Transition timing for sidebar width changes
|
||||
const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
|
||||
const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
|
||||
const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
|
||||
|
||||
export const useSidebar = () => {
|
||||
const collapsed = useSelector(
|
||||
@@ -19,6 +20,7 @@ export const useSidebar = () => {
|
||||
expandedWidth: SIDEBAR_WIDTH_VAR,
|
||||
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
|
||||
transition: SIDEBAR_TRANSITION,
|
||||
transitionTiming: SIDEBAR_TRANSITION_TIMING,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -59,7 +59,12 @@ export const HeaderTeamControls = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Icon icon={Mail} />}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={Mail}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
onClick={onInviteClick}
|
||||
>
|
||||
{t("common.buttons.inviteMember")}
|
||||
@@ -69,7 +74,12 @@ export const HeaderTeamControls = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Icon icon={UserPlus} />}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={UserPlus}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
onClick={onAddMemberClick}
|
||||
>
|
||||
{t("common.buttons.addMember")}
|
||||
|
||||
@@ -17,13 +17,14 @@ import { useParams } from "react-router-dom";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const Checks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { monitorId } = useParams<{ monitorId?: string }>();
|
||||
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
|
||||
const [dateRange, setDateRange] = useState<string>("recent");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("down");
|
||||
const [page, setPage] = useState<number>(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,51 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { StatBox } from "@/Components/design-elements";
|
||||
|
||||
// Types
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainerInfo } from "@/Types/Check";
|
||||
|
||||
// Utils
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
|
||||
export const DockerContainerStatusBoxes = ({
|
||||
container,
|
||||
}: {
|
||||
container: DockerContainerInfo | undefined;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
const { state, restartCount, health } = container;
|
||||
const startedAt = container.startedAt ?? "0";
|
||||
const runningFor = Date.now() - new Date(startedAt).getTime();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
flexWrap={"wrap"}
|
||||
>
|
||||
<StatBox
|
||||
title={t("common.labels.state")}
|
||||
subtitle={state}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("common.labels.uptime")}
|
||||
subtitle={formatDuration(runningFor)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("common.labels.restarts")}
|
||||
subtitle={String(restartCount ?? 0)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("common.labels.health")}
|
||||
subtitle={health}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const TabLogs = () => {
|
||||
return "This is the logs tab";
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
// Components
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
|
||||
|
||||
// Hooks
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
// Types
|
||||
import type { Theme } from "@mui/material";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { BaseBox } from "@/Components/design-elements";
|
||||
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
|
||||
|
||||
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
|
||||
{
|
||||
title: t("common.charts.labels.cpuUsage"),
|
||||
key: "avgCpuPct",
|
||||
color: theme.palette.success.main,
|
||||
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
|
||||
},
|
||||
{
|
||||
title: t("common.charts.labels.memoryUsage"),
|
||||
key: "avgMemoryUsedBytes",
|
||||
color: theme.palette.primary.main,
|
||||
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
|
||||
},
|
||||
];
|
||||
|
||||
export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const { ports, mounts } = stats?.latest?.container ?? {};
|
||||
const dedupedPorts = dedupeDockerPorts(ports ?? []);
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={LAYOUT.MD}
|
||||
>
|
||||
{getChartConfigs(theme, t, stats).map((config) => {
|
||||
return (
|
||||
<Grid
|
||||
key={config.key}
|
||||
size={isSmall ? 12 : 6}
|
||||
>
|
||||
<HistogramDockerContainer
|
||||
title={config.title}
|
||||
rightTitle={config.rightTitle}
|
||||
stats={stats}
|
||||
dataKey={config.key}
|
||||
strokeColor={config.color}
|
||||
gradientStartColor={config.color}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
<Grid size={isSmall ? 12 : 6}>
|
||||
<BaseBox
|
||||
padding={LAYOUT.MD}
|
||||
minHeight={200}
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
textTransform={"uppercase"}
|
||||
>
|
||||
Ports
|
||||
</Typography>
|
||||
<Stack>
|
||||
{dedupedPorts?.map((port) => {
|
||||
return (
|
||||
<Typography
|
||||
key={`${port.publicPort}/${port.privatePort}`}
|
||||
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
</Grid>
|
||||
<Grid size={isSmall ? 12 : 6}>
|
||||
<BaseBox
|
||||
padding={LAYOUT.MD}
|
||||
minHeight={200}
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
textTransform={"uppercase"}
|
||||
>
|
||||
Volumes
|
||||
</Typography>
|
||||
<Stack>
|
||||
{mounts?.map((mount) => {
|
||||
return (
|
||||
<Typography key={`${mount.source}/${mount.destination}`}>
|
||||
{`${getDockerMountLabel(mount)} -> ${mount.destination}`}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
// Components
|
||||
import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
|
||||
import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
|
||||
import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs";
|
||||
|
||||
// Hooks
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import type { DockerContainerResponse } from "@/Types/Monitor";
|
||||
import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
import { HeaderTimeRange } from "@/Components/common";
|
||||
interface DockerContainerDetailsProps {
|
||||
open?: "overview" | "logs";
|
||||
}
|
||||
|
||||
const TAB_MAP = {
|
||||
overview: 0,
|
||||
logs: 1,
|
||||
} as const;
|
||||
|
||||
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
|
||||
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const { monitorId, containerName } = useParams<{
|
||||
monitorId: string;
|
||||
containerName: string;
|
||||
}>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const containerDetailsUrl = monitorId
|
||||
? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
|
||||
: null;
|
||||
|
||||
const { data: containerData } = useGet<DockerContainerResponse>(
|
||||
containerDetailsUrl,
|
||||
{},
|
||||
{ refreshInterval: 10000, keepPreviousData: true }
|
||||
);
|
||||
|
||||
const monitor = containerData?.monitor;
|
||||
if (!monitor) return null;
|
||||
|
||||
const stats = containerData.stats;
|
||||
|
||||
const container = stats?.latest?.container;
|
||||
if (!container) return null;
|
||||
|
||||
const { state, name, image } = container;
|
||||
|
||||
return (
|
||||
<BasePage headerKey="docker.container">
|
||||
<DockerStateLabel
|
||||
state={state}
|
||||
name={name}
|
||||
image={image}
|
||||
/>
|
||||
<DockerContainerStatusBoxes container={container} />
|
||||
<HeaderTimeRange
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, newValue: number) => setActiveTab(newValue)}
|
||||
>
|
||||
<Tab label={t("pages.docker.container.tabs.overview")} />
|
||||
<Tab label={t("pages.docker.container.tabs.logs")} />
|
||||
</Tabs>
|
||||
{activeTab === 0 && <TabOverview stats={stats} />}
|
||||
{activeTab === 1 && <TabLogs />}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerDetailsPage;
|
||||
@@ -0,0 +1,123 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { DockerStateLabel, Table } from "@/Components/design-elements";
|
||||
// Hooks
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// Types
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import type { DockerContainerInfo } from "@/Types/Check";
|
||||
|
||||
// Utils
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
|
||||
interface DockerContainersTableProps {
|
||||
monitorId: string | undefined;
|
||||
containers: DockerContainerInfo[];
|
||||
onRowClick?: (container: DockerContainerInfo) => void;
|
||||
}
|
||||
|
||||
export const DockerContainersTable = ({
|
||||
monitorId,
|
||||
containers,
|
||||
}: DockerContainersTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getHeaders = () => {
|
||||
// const renderSortIcon = (isActive: boolean) => (
|
||||
// <Box
|
||||
// width={16}
|
||||
// display="inline-flex"
|
||||
// justifyContent="center"
|
||||
// >
|
||||
// {isActive ? (
|
||||
// sortOrder === "asc" ? (
|
||||
// <ArrowUp size={16} />
|
||||
// ) : (
|
||||
// <ArrowDown size={16} />
|
||||
// )
|
||||
// ) : null}
|
||||
// </Box>
|
||||
// );
|
||||
const headers: Header<DockerContainerInfo>[] = [
|
||||
{
|
||||
id: "container",
|
||||
content: t("pages.docker.host.table.headers.container"),
|
||||
render: (row) => {
|
||||
return (
|
||||
<DockerStateLabel
|
||||
state={row.state}
|
||||
name={row.name}
|
||||
image={row.image}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: t("common.table.headers.status"),
|
||||
render: (row) => {
|
||||
return <Typography>{row.state}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "health",
|
||||
content: t("common.labels.health"),
|
||||
render: (row) => {
|
||||
return <Typography>{row.health}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
content: t("pages.docker.host.table.headers.cpu"),
|
||||
render: (row) => {
|
||||
return <Typography>{formatPercentage(row.cpuPct ?? 0)}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
content: t("pages.docker.host.table.headers.memory"),
|
||||
render: (row) => {
|
||||
return <Typography>{formatPercentage(row.memoryPct ?? 0)}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "restarts",
|
||||
content: t("common.labels.restarts"),
|
||||
render: (row) => {
|
||||
return <Typography>{row.restartCount}</Typography>;
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
let headers = getHeaders();
|
||||
|
||||
if (isSmall) {
|
||||
headers = headers.filter((h) => h.id !== "histogram");
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Table
|
||||
headers={headers}
|
||||
data={containers}
|
||||
onRowClick={(row) => {
|
||||
navigate(`/docker/host/${monitorId}/container/${row.name}`);
|
||||
}}
|
||||
// getRowSx={(row) => ({
|
||||
// backgroundColor: isRowSelected(row.id)
|
||||
// ? theme.palette.action.selected
|
||||
// : "inherit",
|
||||
// })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { StatBox } from "@/Components/design-elements";
|
||||
|
||||
// Types
|
||||
import type { DockerStats } from "@/Types/Monitor";
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Utils
|
||||
|
||||
export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const { total, running, stopped, unhealthy } = stats.latest?.summary ?? {};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
flexWrap={"wrap"}
|
||||
>
|
||||
<StatBox
|
||||
title={t("pages.docker.host.statBoxes.containers")}
|
||||
subtitle={String(total)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.docker.host.statBoxes.running")}
|
||||
subtitle={String(running)}
|
||||
/>
|
||||
|
||||
<StatBox
|
||||
title={t("pages.docker.host.statBoxes.stopped")}
|
||||
subtitle={String(stopped)}
|
||||
/>
|
||||
|
||||
<StatBox
|
||||
title={t("pages.docker.host.statBoxes.unhealthy")}
|
||||
subtitle={String(unhealthy)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BasePage } from "@/Components/design-elements";
|
||||
import { HeaderMonitorControls, MonitorStatBoxes } from "@/Components/monitors";
|
||||
import { DockerStatusBoxes } from "@/Pages/Docker/HostDetails/Components/StatBoxes";
|
||||
|
||||
// Types
|
||||
import type { DockerDetailsResponse } from "@/Types/Monitor";
|
||||
|
||||
// Hooks
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { DockerContainersTable } from "@/Pages/Docker/HostDetails/Components/DockerContainersTable";
|
||||
|
||||
const DockerHostDetailsPage = () => {
|
||||
const { monitorId } = useParams<{ monitorId: string }>();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// The containers list renders the latest check only, so no date range is
|
||||
// offered; the endpoint's dateRange param only shapes the aggregate buckets.
|
||||
const monitorDetailsUrl = monitorId ? `/monitors/docker/details/${monitorId}` : null;
|
||||
|
||||
const { data: monitorDetailsData, refetch: refetchMonitor } =
|
||||
useGet<DockerDetailsResponse>(
|
||||
monitorDetailsUrl,
|
||||
{},
|
||||
{ refreshInterval: 10000, keepPreviousData: true }
|
||||
);
|
||||
|
||||
const monitor = monitorDetailsData?.monitor;
|
||||
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||
const stats = monitorDetailsData?.stats;
|
||||
const containers = stats?.latest?.containers ?? [];
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<HeaderMonitorControls
|
||||
path="docker"
|
||||
monitor={monitor}
|
||||
isAdmin={isAdmin}
|
||||
refetch={refetchMonitor}
|
||||
/>
|
||||
<MonitorStatBoxes
|
||||
monitor={monitor}
|
||||
monitorStats={monitorStats}
|
||||
/>
|
||||
<DockerStatusBoxes stats={stats} />
|
||||
<DockerContainersTable
|
||||
monitorId={monitorId}
|
||||
containers={containers}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerHostDetailsPage;
|
||||
@@ -0,0 +1,343 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
ColoredLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { DockerContainerSummary } from "@/Types/Check";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
const summaryOf = (monitor: Monitor): DockerContainerSummary | undefined => {
|
||||
const recentChecks = monitor.recentChecks ?? [];
|
||||
return recentChecks[recentChecks.length - 1]?.containerSummary;
|
||||
};
|
||||
|
||||
interface DockerMonitorsTableProps {
|
||||
monitors: Monitor[];
|
||||
tags: Tag[];
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
selectedRows?: string[];
|
||||
onSelectionChange?: (selected: string[]) => void;
|
||||
}
|
||||
|
||||
export const DockerMonitorsTable = ({
|
||||
monitors,
|
||||
tags,
|
||||
refetch,
|
||||
setSelectedMonitor,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
selectedRows = [],
|
||||
onSelectionChange,
|
||||
}: DockerMonitorsTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const navigate = useNavigate();
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
|
||||
const selectedSet = new Set(selectedRows);
|
||||
const isAllSelected =
|
||||
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
|
||||
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
|
||||
};
|
||||
|
||||
const handleSelectRow = (id: string, checked: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
onSelectionChange(
|
||||
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const isRowSelected = (id: string) => selectedSet.has(id);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
newPage: number
|
||||
) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleRowsPerPageChange = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
|
||||
) => {
|
||||
const value = Number(e.target.value);
|
||||
setPage(0);
|
||||
setRowsPerPage(value);
|
||||
};
|
||||
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
||||
setSortOrder(newOrder);
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
refetch();
|
||||
};
|
||||
|
||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||
return [
|
||||
{
|
||||
id: 2,
|
||||
label: t("pages.common.monitors.actions.details"),
|
||||
action: () => {
|
||||
navigate(`${monitor.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: t("pages.common.monitors.actions.incidents"),
|
||||
action: () => {
|
||||
navigate(`/incidents?monitorId=${monitor.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: t("pages.common.monitors.actions.configure"),
|
||||
action: () => {
|
||||
navigate(`/docker/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
monitor.isActive === false
|
||||
? t("common.buttons.resume")
|
||||
: t("common.buttons.pause"),
|
||||
action: async () => {
|
||||
await post(`/monitors/pause/${monitor.id}`, {});
|
||||
refetch();
|
||||
},
|
||||
closeMenu: true,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
label: (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("common.buttons.delete")}
|
||||
</Typography>
|
||||
),
|
||||
action: () => {
|
||||
setSelectedMonitor(monitor);
|
||||
},
|
||||
closeMenu: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getHeaders = () => {
|
||||
const renderSortIcon = (isActive: boolean) => (
|
||||
<Box
|
||||
width={16}
|
||||
display="inline-flex"
|
||||
justifyContent="center"
|
||||
>
|
||||
{isActive ? (
|
||||
sortOrder === "asc" ? (
|
||||
<ArrowUp size={16} />
|
||||
) : (
|
||||
<ArrowDown size={16} />
|
||||
)
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("pages.docker.table.headers.host"),
|
||||
content: (
|
||||
<Typography
|
||||
component="div"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
onClick={(e) => handleSort(e, "name")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("pages.docker.table.headers.host")}
|
||||
{renderSortIcon(sortField === "name")}
|
||||
</Typography>
|
||||
),
|
||||
render: (row) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Typography variant="body2">{row.name}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{row.url}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
onClick={(e) => handleSort(e, "status")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.status")}
|
||||
{renderSortIcon(sortField === "status")}
|
||||
</Stack>
|
||||
),
|
||||
render: (row) => {
|
||||
return <StatusLabel status={row.status} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "containers",
|
||||
content: t("pages.docker.table.headers.containers"),
|
||||
render: (row) => {
|
||||
const summary = summaryOf(row);
|
||||
return (
|
||||
<Typography variant="body2">
|
||||
{summary
|
||||
? t("pages.docker.table.containersRunning", {
|
||||
running: summary.running,
|
||||
total: summary.total,
|
||||
})
|
||||
: "—"}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
content: t("common.table.headers.tags"),
|
||||
render: (row) => {
|
||||
if (row.tags.length === 0) return "-";
|
||||
return (
|
||||
<Stack
|
||||
justifyContent={"center"}
|
||||
direction={isSmall ? "column" : "row"}
|
||||
gap={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
{row.tags.map((tagId) => {
|
||||
const fullTag = tags.find((t) => t.id === tagId);
|
||||
if (!fullTag) return null;
|
||||
return (
|
||||
<ColoredLabel
|
||||
key={fullTag.id}
|
||||
text={fullTag.name}
|
||||
color={fullTag.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
content: t("common.table.headers.actions"),
|
||||
render: (row) => {
|
||||
return <ActionsMenu items={getActions(row)} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
let headers = getHeaders();
|
||||
|
||||
if (isSmall) {
|
||||
headers = headers.filter((h) => h.id !== "histogram");
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Table
|
||||
headers={headers}
|
||||
data={monitors}
|
||||
onRowClick={(row) => {
|
||||
navigate(`/docker/host/${row.id}`);
|
||||
}}
|
||||
isRowSelected={(row) => isRowSelected(row.id)}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
count={count}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
itemsOnPage={monitors.length}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MonitorListPage } from "@/Components/monitors";
|
||||
import { DockerMonitorsTable } from "@/Pages/Docker/Monitors/Components/DockerMonitorsTable";
|
||||
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||
|
||||
const DockerMonitorsPage = () => {
|
||||
const c = useMonitorListController({
|
||||
types: ["docker"],
|
||||
checksLimit: 25,
|
||||
refreshInterval: 5000,
|
||||
rowsPerPageTable: "docker",
|
||||
rowsPerPageDefault: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<MonitorListPage
|
||||
headerKey="docker"
|
||||
page="docker"
|
||||
actionLink="/docker/create"
|
||||
controller={c}
|
||||
bulkActions
|
||||
showTypeFilter={false}
|
||||
>
|
||||
<DockerMonitorsTable
|
||||
monitors={c.monitors || []}
|
||||
tags={c.tags || []}
|
||||
refetch={c.refetch}
|
||||
setSelectedMonitor={c.setSelectedMonitor}
|
||||
count={c.count || 0}
|
||||
page={c.page}
|
||||
setPage={c.setPage}
|
||||
rowsPerPage={c.rowsPerPage}
|
||||
sortField={c.sortField}
|
||||
setSortField={c.setSortField}
|
||||
sortOrder={c.sortOrder}
|
||||
setSortOrder={c.setSortOrder}
|
||||
setRowsPerPage={c.handleSetRowsPerPage}
|
||||
selectedRows={c.selectedRows}
|
||||
onSelectionChange={c.setSelectedRows}
|
||||
/>
|
||||
</MonitorListPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerMonitorsPage;
|
||||
@@ -30,6 +30,10 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
// The icons are supporting marks beside the label, not content,
|
||||
// so they take secondary ink rather than lucide's currentColor
|
||||
// default, which renders them heavier than the text.
|
||||
sx={{ "& svg": { color: theme.palette.text.secondary } }}
|
||||
>
|
||||
{icon}
|
||||
<Typography variant="body1">{label}</Typography>
|
||||
@@ -143,7 +147,10 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
|
||||
gap: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Icon icon={Globe} />
|
||||
<Icon
|
||||
icon={Globe}
|
||||
color={theme.palette.text.secondary}
|
||||
/>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
const IncidentsPage = () => {
|
||||
@@ -30,7 +31,7 @@ const IncidentsPage = () => {
|
||||
// Filter state
|
||||
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [dateRange, setDateRange] = useState("recent");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
|
||||
|
||||
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
|
||||
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
|
||||
return [
|
||||
{
|
||||
title: t("pages.infrastructure.charts.labels.memory"),
|
||||
title: t("common.charts.labels.memoryUsage"),
|
||||
type: "memory",
|
||||
dataKeys: ["avgMemoryUsage"],
|
||||
strokeColor: theme.palette.primary.main,
|
||||
@@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
|
||||
idx: null,
|
||||
},
|
||||
{
|
||||
title: t("pages.infrastructure.charts.labels.cpu"),
|
||||
title: t("common.charts.labels.cpuUsage"),
|
||||
type: "cpu",
|
||||
dataKeys: ["avgCpuUsage"],
|
||||
strokeColor: theme.palette.success.main,
|
||||
|
||||
@@ -63,19 +63,42 @@ const getChartConfigs = (
|
||||
return configs;
|
||||
};
|
||||
|
||||
// Loopback / bridge names Capture sees when it can only observe the container's
|
||||
// own network namespace. When ALL reported interfaces match this set we assume
|
||||
// the "no real interfaces visible" case and surface the notice.
|
||||
// Loopback / bridge names Capture reports when it can only observe the
|
||||
// container's own network namespace.
|
||||
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
|
||||
|
||||
// Below this, an interface is carrying no traffic worth charting. Real host
|
||||
// NICs sit orders of magnitude above it; a container's veth sits at or near
|
||||
// zero.
|
||||
const IDLE_INTERFACE_BYTES_PER_SECOND = 1024;
|
||||
|
||||
const interfaceIsIdle = (checks: HardwareCheckStats[], name: string): boolean =>
|
||||
checks.every((check) => {
|
||||
const iface = check.net?.find((candidate) => candidate.name === name);
|
||||
if (!iface) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(iface.bytesSentPerSecond || 0) < IDLE_INTERFACE_BYTES_PER_SECOND &&
|
||||
(iface.deltaBytesRecv || 0) < IDLE_INTERFACE_BYTES_PER_SECOND
|
||||
);
|
||||
});
|
||||
|
||||
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
|
||||
const named = new Set<string>();
|
||||
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
|
||||
if (named.size === 0) return false;
|
||||
for (const name of named) {
|
||||
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
|
||||
if (named.size === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
const names = [...named];
|
||||
if (names.every((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
|
||||
return true;
|
||||
}
|
||||
if (!names.some((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
|
||||
return false;
|
||||
}
|
||||
return names.every((name) => interfaceIsIdle(checks, name));
|
||||
};
|
||||
|
||||
export const InfraNetworkCharts = ({
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useTheme } from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
|
||||
const GAUGE_MAX_WIDTH = 260;
|
||||
|
||||
export const InfraDetailsGauges = ({
|
||||
snapshot,
|
||||
}: {
|
||||
@@ -26,37 +28,42 @@ export const InfraDetailsGauges = ({
|
||||
direction={isSmall ? "column" : "row"}
|
||||
spacing={theme.spacing(8)}
|
||||
alignItems={"stretch"}
|
||||
flexWrap={"wrap"}
|
||||
useFlexGap
|
||||
>
|
||||
<DetailGauge
|
||||
title={t("pages.infrastructure.gauges.memory.title")}
|
||||
maxWidth={260}
|
||||
maxWidth={GAUGE_MAX_WIDTH}
|
||||
progress={(snapshot?.memory?.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
|
||||
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
|
||||
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
|
||||
lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)}
|
||||
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
|
||||
/>
|
||||
<DetailGauge
|
||||
title={t("pages.infrastructure.gauges.cpu.title")}
|
||||
maxWidth={260}
|
||||
maxWidth={GAUGE_MAX_WIDTH}
|
||||
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
|
||||
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
|
||||
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
|
||||
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
|
||||
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
|
||||
/>
|
||||
{snapshot?.disk?.map((disk, idx) => {
|
||||
return (
|
||||
<DetailGauge
|
||||
key={disk?.device || 0 + idx}
|
||||
key={disk?.device || `disk-${idx}`}
|
||||
// title={`Disk ${idx} usage`}
|
||||
title={t("pages.infrastructure.gauges.disk.title", { idx })}
|
||||
maxWidth={260}
|
||||
maxWidth={GAUGE_MAX_WIDTH}
|
||||
progress={(disk.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
|
||||
upperValue={prettyBytes(disk?.used_bytes || 0)}
|
||||
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
|
||||
lowerValue={prettyBytes(disk?.total_bytes || 0)}
|
||||
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useGet } from "@/Hooks/UseApi";
|
||||
import type { HardwareDetailsResponse } from "@/Types/Monitor";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const InfrastructureDetails = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -17,7 +18,7 @@ const InfrastructureDetails = () => {
|
||||
|
||||
const { monitorId } = useParams<{ monitorId: string }>();
|
||||
|
||||
const [dateRange, setDateRange] = useState<string>("recent");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
||||
|
||||
const monitorDetailsUrl = useMemo(() => {
|
||||
|
||||
@@ -331,11 +331,7 @@ export const InfraMonitorsTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/infrastructure/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
isRowSelected={(row) => isRowSelected(row.id)}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
|
||||
import { useTheme } from "@mui/material";
|
||||
@@ -15,6 +16,7 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||
import { mutate } from "swr";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||
@@ -41,6 +43,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
);
|
||||
|
||||
const { data: monitors } = useGet<Monitor[]>("/monitors/team");
|
||||
const { data: tags } = useGet<Tag[]>("/tags/team");
|
||||
|
||||
const { post, loading: isPosting } = usePost();
|
||||
const { patch, loading: isPatching } = usePatch();
|
||||
@@ -203,6 +206,26 @@ const CreateMaintenanceWindowPage = () => {
|
||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||
)}
|
||||
options={monitors ?? []}
|
||||
renderOptionContent={(monitor) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Typography>{monitor.name}</Typography>
|
||||
{monitor.tags.map((tagId) => {
|
||||
const tag = tags?.find(({ id }) => id === tagId);
|
||||
return tag ? (
|
||||
<ColoredLabel
|
||||
key={tag.id}
|
||||
text={tag.name}
|
||||
color={tag.color}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -15,12 +15,13 @@ import { useGet } from "@/Hooks/UseApi";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useState, useMemo } from "react";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const PageSpeedDetails = () => {
|
||||
const { monitorId } = useParams();
|
||||
const isAdmin = useIsAdmin();
|
||||
const theme = useTheme();
|
||||
const [dateRange, setDateRange] = useState<string>("day");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("day");
|
||||
|
||||
const monitorDetailsUrl = useMemo(() => {
|
||||
if (!monitorId) {
|
||||
|
||||
+691
-518
File diff suppressed because it is too large.
Load diff
@@ -25,7 +25,12 @@ export const HeaderConfigStatusControls = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<Icon icon={Trash} />}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={Trash}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{translate.t("common.buttons.delete")}
|
||||
|
||||
@@ -77,7 +77,12 @@ export const HeaderStatusPageControls = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon={<Icon icon={Settings} />}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={Settings}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
onClick={() => navigate(`/status/configure/${statusPage.url}`)}
|
||||
>
|
||||
{t("common.buttons.configure")}
|
||||
|
||||
@@ -211,6 +211,7 @@ export const BaseStatusPage = ({
|
||||
>
|
||||
<Icon
|
||||
icon={TriangleAlert}
|
||||
size={20}
|
||||
color={theme.palette.warning.main}
|
||||
/>
|
||||
<Typography>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// No "Inter" here: its webfont was never self-hosted, so once the Google Fonts
|
||||
// import went it resolved to a fallback anyway. Listing it only made the stack
|
||||
// look intentional on machines that happen to have Inter installed.
|
||||
export const SANS_STACK =
|
||||
'-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { RootState } from "@/Types/state";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { t } from "i18next";
|
||||
import { Typography } from "@mui/material";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const certificateDateFormat = "MMM D, YYYY h A";
|
||||
|
||||
@@ -53,7 +54,7 @@ const UptimeDetailsPage = () => {
|
||||
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
|
||||
const [geoPage, setGeoPage] = useState<number>(0);
|
||||
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
|
||||
const [dateRange, setDateRange] = useState<string>("recent");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
|
||||
|
||||
const monitorDetailsUrl = useMemo(() => {
|
||||
|
||||
@@ -350,11 +350,7 @@ export const MonitorTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/uptime/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
isRowSelected={(row) => isRowSelected(row.id)}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -20,6 +20,11 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/";
|
||||
import Infrastructure from "@/Pages/Infrastructure/Monitors";
|
||||
import InfrastructureDetails from "@/Pages/Infrastructure/Details";
|
||||
|
||||
// Docker
|
||||
import Docker from "@/Pages/Docker/Monitors";
|
||||
import DockerHostDetails from "@/Pages/Docker/HostDetails";
|
||||
import DockerDetails from "@/Pages/Docker/ContainerDetails";
|
||||
|
||||
// Checks
|
||||
import Checks from "@/Pages/Checks";
|
||||
|
||||
@@ -139,6 +144,26 @@ const Routes = () => {
|
||||
path="infrastructure/:monitorId"
|
||||
element={<InfrastructureDetails />}
|
||||
/>
|
||||
<Route
|
||||
path="docker"
|
||||
element={<Docker />}
|
||||
/>
|
||||
<Route
|
||||
path="docker/create"
|
||||
element={<CreateMonitor />}
|
||||
/>
|
||||
<Route
|
||||
path="/docker/configure/:monitorId"
|
||||
element={<CreateMonitor />}
|
||||
/>
|
||||
<Route
|
||||
path="docker/host/:monitorId/container/:containerName"
|
||||
element={<DockerDetails />}
|
||||
/>
|
||||
<Route
|
||||
path="docker/host/:monitorId"
|
||||
element={<DockerHostDetails />}
|
||||
/>
|
||||
<Route
|
||||
path="checks/:monitorId?"
|
||||
element={<Checks />}
|
||||
|
||||
@@ -119,6 +119,65 @@ export interface CheckTimings {
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors DockerContainerSummary in server/src/types/network.ts.
|
||||
export interface DockerContainerSummary {
|
||||
total: number;
|
||||
running: number;
|
||||
stopped: number;
|
||||
unhealthy: number;
|
||||
}
|
||||
|
||||
// Mirrors DockerContainerStates in server/src/types/network.ts.
|
||||
export const DockerContainerStates = [
|
||||
"created",
|
||||
"running",
|
||||
"paused",
|
||||
"restarting",
|
||||
"removing",
|
||||
"exited",
|
||||
"dead",
|
||||
] as const;
|
||||
export type DockerContainerState = (typeof DockerContainerStates)[number];
|
||||
|
||||
export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const;
|
||||
export type DockerHealthStatus = (typeof DockerHealthStatuses)[number];
|
||||
|
||||
export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
|
||||
export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
|
||||
|
||||
export interface DockerContainerPort {
|
||||
privatePort: number;
|
||||
protocol: DockerPortProtocol;
|
||||
publicPort?: number;
|
||||
hostIp?: string;
|
||||
}
|
||||
|
||||
export interface DockerContainerMount {
|
||||
type: string;
|
||||
name?: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
rw: boolean;
|
||||
}
|
||||
|
||||
export interface DockerContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: DockerContainerState;
|
||||
status: string;
|
||||
health: DockerHealthStatus;
|
||||
cpuPct?: number;
|
||||
memoryUsedBytes?: number;
|
||||
memoryLimitBytes?: number;
|
||||
memoryPct?: number;
|
||||
restartCount?: number;
|
||||
startedAt?: string;
|
||||
ports?: DockerContainerPort[];
|
||||
mounts?: DockerContainerMount[];
|
||||
}
|
||||
|
||||
export interface Check {
|
||||
id: string;
|
||||
metadata: CheckMetadata;
|
||||
@@ -133,6 +192,7 @@ export interface Check {
|
||||
host?: CheckHostInfo;
|
||||
errors?: CheckErrorInfo[];
|
||||
capture?: CheckCaptureInfo;
|
||||
containerSummary?: DockerContainerSummary;
|
||||
net?: CheckNetworkInterfaceInfo[];
|
||||
accessibility?: number;
|
||||
bestPractices?: number;
|
||||
@@ -287,6 +347,7 @@ export type CheckSnapshot = Pick<
|
||||
memory?: SnapshotMemoryInfo;
|
||||
disk?: SnapshotDiskInfo[];
|
||||
host?: SnapshotHostInfo;
|
||||
containerSummary?: DockerContainerSummary;
|
||||
};
|
||||
export interface HasResponseTime {
|
||||
responseTime: number;
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
CheckSnapshot,
|
||||
GroupedUptimeCheck,
|
||||
DailyCheckBucket,
|
||||
DockerContainerInfo,
|
||||
DockerContainerSummary,
|
||||
} from "@/Types/Check";
|
||||
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||
@@ -30,7 +32,6 @@ export type MonitorType = (typeof MonitorTypes)[number];
|
||||
export const SelectableMonitorTypes = [
|
||||
"http",
|
||||
"ping",
|
||||
"docker",
|
||||
"port",
|
||||
"game",
|
||||
"grpc",
|
||||
@@ -269,6 +270,58 @@ export interface HardwareDetailsResponse {
|
||||
monitorStats: MonitorStats | null;
|
||||
}
|
||||
|
||||
export interface DockerStatsBucket {
|
||||
_id: string;
|
||||
avgResponseTime: number | null;
|
||||
upCount: number;
|
||||
totalCount: number;
|
||||
avgRunning: number | null;
|
||||
avgTotal: number | null;
|
||||
avgUnhealthy: number | null;
|
||||
}
|
||||
|
||||
export interface DockerStats {
|
||||
aggregateData: {
|
||||
totalChecks: number;
|
||||
};
|
||||
upChecks: {
|
||||
totalChecks: number;
|
||||
};
|
||||
aggregate: DockerStatsBucket[];
|
||||
latest: {
|
||||
containers: DockerContainerInfo[];
|
||||
summary?: DockerContainerSummary;
|
||||
checkedAt: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface DockerDetailsResponse {
|
||||
monitor: Monitor;
|
||||
stats: DockerStats;
|
||||
monitorStats: MonitorStats | null;
|
||||
}
|
||||
export interface DockerContainerStatsBucket {
|
||||
_id: string;
|
||||
avgCpuPct: number | null;
|
||||
avgMemoryUsedBytes: number | null;
|
||||
avgMemoryPct: number | null;
|
||||
minRestartCount: number | null;
|
||||
maxRestartCount: number | null;
|
||||
}
|
||||
|
||||
export interface DockerContainerStats {
|
||||
aggregate: DockerContainerStatsBucket[];
|
||||
restartsInRange: number;
|
||||
latest: {
|
||||
container: DockerContainerInfo;
|
||||
checkedAt: string;
|
||||
} | null;
|
||||
}
|
||||
export interface DockerContainerResponse {
|
||||
monitor: Monitor;
|
||||
stats: DockerContainerStats;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
name: string;
|
||||
options?: {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
|
||||
export type DateRange = (typeof DateRanges)[number];
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
import type {
|
||||
DockerContainerMount,
|
||||
DockerContainerPort,
|
||||
DockerContainerState,
|
||||
} from "@/Types/Check";
|
||||
|
||||
export const getMonitorPath = (type: MonitorType): string => {
|
||||
const pathMap: Record<MonitorType, string> = {
|
||||
@@ -12,7 +17,7 @@ export const getMonitorPath = (type: MonitorType): string => {
|
||||
websocket: "uptime",
|
||||
dns: "uptime",
|
||||
unknown: "uptime",
|
||||
docker: "uptime",
|
||||
docker: "docker",
|
||||
hardware: "infrastructure",
|
||||
pagespeed: "pagespeed",
|
||||
};
|
||||
@@ -41,6 +46,19 @@ export const getValuePalette = (value: ValueType): PaletteKey => {
|
||||
return paletteMap[value];
|
||||
};
|
||||
|
||||
export const getDockerStatePalette = (state: DockerContainerState): PaletteKey => {
|
||||
const paletteMap: Record<DockerContainerState, PaletteKey> = {
|
||||
paused: "warning",
|
||||
created: "warning",
|
||||
running: "success",
|
||||
restarting: "warning",
|
||||
removing: "warning",
|
||||
exited: "error",
|
||||
dead: "error",
|
||||
};
|
||||
return paletteMap[state];
|
||||
};
|
||||
|
||||
export const getStatusColor = (status: MonitorStatus, theme: any): string => {
|
||||
if (status === "up") {
|
||||
return theme.palette.success.light;
|
||||
@@ -97,3 +115,32 @@ export const formatUrl = (url: string, maxLength: number = 55) => {
|
||||
? `${strippedUrl.slice(0, maxLength)}…`
|
||||
: strippedUrl;
|
||||
};
|
||||
|
||||
export const dedupeDockerPorts = (
|
||||
ports: DockerContainerPort[]
|
||||
): DockerContainerPort[] => {
|
||||
return ports.filter((port) => {
|
||||
// Discard ipv6 wildcards
|
||||
if (port.hostIp !== "::") return port;
|
||||
|
||||
const isDuplicate = ports.some((otherPort) => {
|
||||
if (otherPort.hostIp !== "0.0.0.0") return false;
|
||||
|
||||
return (
|
||||
otherPort.privatePort === port.privatePort &&
|
||||
otherPort.protocol === port.protocol &&
|
||||
otherPort.publicPort === port.publicPort
|
||||
);
|
||||
});
|
||||
return !isDuplicate;
|
||||
});
|
||||
};
|
||||
|
||||
const isAnonymousVolumeName = (name: string): boolean => /^[0-9a-f]{64}$/.test(name);
|
||||
|
||||
export const getDockerMountLabel = (mount: DockerContainerMount) => {
|
||||
if (mount.type === "volume" && mount.name)
|
||||
return isAnonymousVolumeName(mount.name) ? mount.name.slice(0, 12) : mount.name;
|
||||
if (mount.source) return mount.source;
|
||||
return mount.type;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { HOVER } from "@/Utils/Theme/constants";
|
||||
|
||||
const typographyBase = 13;
|
||||
|
||||
@@ -17,6 +18,20 @@ export const colors = {
|
||||
gray700: "#313131",
|
||||
gray900: "#151518",
|
||||
gray850: "#1c1c21",
|
||||
ink900: "#101917",
|
||||
ink600: "#55635C",
|
||||
ink400: "#7C8983",
|
||||
surface50: "#F6F8F7",
|
||||
rule200: "#C7D2CC",
|
||||
successMain: "#0D6049",
|
||||
errorMain: "#A02B24",
|
||||
warningMain: "#8C5806",
|
||||
inkDark900: "#ECEFED",
|
||||
inkDark600: "#A3ADA8",
|
||||
ruleDark: "#3A4642",
|
||||
successDark: "#4BC49E",
|
||||
errorDark: "#E8837A",
|
||||
warningDark: "#E0A458",
|
||||
brandGreen: "#13715B",
|
||||
brandGreenLight: "#4DAF94",
|
||||
green200: "#ECF7F2",
|
||||
@@ -39,9 +54,34 @@ export const lightPalette = {
|
||||
primary: {
|
||||
main: colors.brandGreen,
|
||||
},
|
||||
action: {
|
||||
rowHover: alpha(colors.gray900, HOVER.ROW),
|
||||
controlHover: alpha(colors.gray900, HOVER.CONTROL),
|
||||
selected: alpha(colors.brandGreen, 0.06),
|
||||
selectedHover: alpha(colors.brandGreen, 0.1),
|
||||
},
|
||||
secondary: {
|
||||
main: colors.gray200,
|
||||
},
|
||||
background: {
|
||||
default: colors.surface50,
|
||||
paper: "#FFFFFF",
|
||||
},
|
||||
text: {
|
||||
primary: colors.ink900,
|
||||
secondary: colors.ink600,
|
||||
disabled: colors.ink400,
|
||||
},
|
||||
divider: colors.rule200,
|
||||
success: {
|
||||
main: colors.successMain,
|
||||
},
|
||||
error: {
|
||||
main: colors.errorMain,
|
||||
},
|
||||
warning: {
|
||||
main: colors.warningMain,
|
||||
},
|
||||
sidebar: {
|
||||
accent: colors.brandGreen,
|
||||
},
|
||||
@@ -64,6 +104,13 @@ export const lightPalette = {
|
||||
export const darkPalette = {
|
||||
primary: {
|
||||
main: colors.brandGreen,
|
||||
light: colors.brandGreenLight,
|
||||
},
|
||||
action: {
|
||||
rowHover: alpha("#FFFFFF", HOVER.ROW),
|
||||
controlHover: alpha("#FFFFFF", HOVER.CONTROL),
|
||||
selected: alpha(colors.brandGreenLight, 0.07),
|
||||
selectedHover: alpha(colors.brandGreenLight, 0.11),
|
||||
},
|
||||
secondary: {
|
||||
main: colors.gray700,
|
||||
@@ -72,6 +119,21 @@ export const darkPalette = {
|
||||
default: colors.gray900,
|
||||
paper: colors.gray850,
|
||||
},
|
||||
text: {
|
||||
primary: colors.inkDark900,
|
||||
secondary: colors.inkDark600,
|
||||
disabled: colors.ink400,
|
||||
},
|
||||
divider: colors.ruleDark,
|
||||
success: {
|
||||
main: colors.successDark,
|
||||
},
|
||||
error: {
|
||||
main: colors.errorDark,
|
||||
},
|
||||
warning: {
|
||||
main: colors.warningDark,
|
||||
},
|
||||
sidebar: {
|
||||
accent: colors.brandGreenLight,
|
||||
},
|
||||
|
||||
@@ -13,6 +13,11 @@ declare module "@mui/material/styles" {
|
||||
eyebrow?: React.CSSProperties;
|
||||
fontFamilyMonospace?: string;
|
||||
}
|
||||
interface TypeAction {
|
||||
rowHover: string;
|
||||
controlHover: string;
|
||||
selectedHover: string;
|
||||
}
|
||||
interface Palette {
|
||||
sidebar: { accent: string };
|
||||
rowStatus: { running: string; paused: string };
|
||||
@@ -53,7 +58,7 @@ export type PaletteKey = {
|
||||
[K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never;
|
||||
}[keyof Theme["palette"]];
|
||||
|
||||
const fontFamilyPrimary = "system-ui, sans-serif";
|
||||
const fontFamilyPrimary = '"Geist Variable", system-ui, sans-serif';
|
||||
const fontFamilyMonospace =
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
|
||||
const shadow =
|
||||
@@ -81,11 +86,13 @@ export const theme = (mode: string, palette: any) =>
|
||||
fontSize: typographyLevels.base,
|
||||
h1: {
|
||||
fontSize: typographyLevels.xxl,
|
||||
fontWeight: 500,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.015em",
|
||||
},
|
||||
h2: {
|
||||
fontSize: typographyLevels.l,
|
||||
fontWeight: 400,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.01em",
|
||||
},
|
||||
body1: {
|
||||
fontSize: typographyLevels.m,
|
||||
@@ -127,7 +134,11 @@ export const theme = (mode: string, palette: any) =>
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
"&.Mui-focusVisible": {
|
||||
outline: `1px solid ${theme.palette.primary.main}`,
|
||||
outline: `1px solid ${
|
||||
theme.palette.mode === "dark"
|
||||
? theme.palette.primary.light
|
||||
: theme.palette.primary.main
|
||||
}`,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
}),
|
||||
@@ -184,15 +195,36 @@ export const theme = (mode: string, palette: any) =>
|
||||
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
input: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
height: "100%",
|
||||
},
|
||||
root: ({ theme }) => ({
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
"& .MuiSelect-select": {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
minHeight: "unset",
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
transition: "border-color 0.15s ease",
|
||||
},
|
||||
"&:hover:not(.Mui-focused):not(.Mui-disabled) .MuiOutlinedInput-notchedOutline":
|
||||
{
|
||||
borderColor: theme.palette.text.disabled,
|
||||
},
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.23)"
|
||||
: theme.palette.text.primary,
|
||||
? theme.palette.primary.light
|
||||
: theme.palette.primary.main,
|
||||
borderWidth: 2,
|
||||
},
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: palette.primary.main,
|
||||
"&.Mui-error .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.error.main,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -18,6 +18,22 @@ export const LAYOUT = {
|
||||
XXL: 16,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shared height for anything that can sit in a row with something else -
|
||||
* buttons, inputs, selects, pickers, toggles, tabs, icon buttons. One number so
|
||||
* they share a baseline instead of drifting 2px apart.
|
||||
*/
|
||||
export const CONTROL_HEIGHT = 32;
|
||||
|
||||
/**
|
||||
* Width of a square-ish icon-only control that sits in a header strip - wider
|
||||
* than CONTROL_HEIGHT so the icon has breathing room either side of the
|
||||
* dividers between them.
|
||||
*/
|
||||
export const ICON_CONTROL_WIDTH = 36;
|
||||
|
||||
export const HOVER = {
|
||||
DARKEN: 0.06, // This is a coefficient for darkening function
|
||||
ROW: 0.025, // Overlay alpha for hoverable rows and cards
|
||||
CONTROL: 0.05, // Stronger overlay for controls nested inside a hoverable row
|
||||
} as const;
|
||||
@@ -1,5 +1,3 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"statusPages": "صفحات الحالة",
|
||||
"maintenance": "الصيانة",
|
||||
"logs": "السجلات",
|
||||
"settings": "الإعدادات"
|
||||
"settings": "الإعدادات",
|
||||
"groups": {
|
||||
"monitoring": "المراقبة",
|
||||
"alerting": "التنبيهات",
|
||||
"reporting": "التقارير",
|
||||
"configuration": "الإعدادات العامة"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "الدعم",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "عام",
|
||||
"monitoring": "المراقبة",
|
||||
"email": "البريد الإلكتروني",
|
||||
"integrations": "التكاملات",
|
||||
"data": "البيانات"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "المنطقة الزمنية للعرض",
|
||||
|
||||
@@ -189,7 +189,13 @@
|
||||
"statusPages": "Pàgines d'estat",
|
||||
"maintenance": "Manteniment",
|
||||
"logs": "Registres",
|
||||
"settings": "Configuració"
|
||||
"settings": "Configuració",
|
||||
"groups": {
|
||||
"monitoring": "Monitoratge",
|
||||
"alerting": "Alertes",
|
||||
"reporting": "Informes",
|
||||
"configuration": "Configuració"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Suport",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
"monitoring": "Monitoratge",
|
||||
"email": "Correu electrònic",
|
||||
"integrations": "Integracions",
|
||||
"data": "Dades"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Zona horària de visualització",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"statusPages": "Stavové stránky",
|
||||
"maintenance": "Údržba",
|
||||
"logs": "Záznamy",
|
||||
"settings": "Nastavení"
|
||||
"settings": "Nastavení",
|
||||
"groups": {
|
||||
"monitoring": "Monitorování",
|
||||
"alerting": "Upozornění",
|
||||
"reporting": "Přehledy",
|
||||
"configuration": "Konfigurace"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Podpora",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "Obecné",
|
||||
"monitoring": "Monitorování",
|
||||
"email": "E-mail",
|
||||
"integrations": "Integrace",
|
||||
"data": "Data"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Zobrazované časové pásmo",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"statusPages": "Statusseite",
|
||||
"maintenance": "Wartung",
|
||||
"logs": "Protokolle",
|
||||
"settings": "Einstellungen"
|
||||
"settings": "Einstellungen",
|
||||
"groups": {
|
||||
"monitoring": "Überwachung",
|
||||
"alerting": "Benachrichtigungen",
|
||||
"reporting": "Berichte",
|
||||
"configuration": "Konfiguration"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Support",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "Allgemein",
|
||||
"monitoring": "Überwachung",
|
||||
"email": "E-Mail",
|
||||
"integrations": "Integrationen",
|
||||
"data": "Daten"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Anzeigesprache Zeitzone",
|
||||
|
||||
@@ -66,9 +66,11 @@
|
||||
"charts": {
|
||||
"labels": {
|
||||
"averageResponseTime": "Average response time",
|
||||
"cpuUsage": "CPU usage",
|
||||
"downtime": "Downtime",
|
||||
"high": "High",
|
||||
"low": "Low",
|
||||
"memoryUsage": "Memory usage",
|
||||
"responseTime": "Response time",
|
||||
"timingPhases": "Timing phases",
|
||||
"totalResponseTime": "Total response time",
|
||||
@@ -102,7 +104,11 @@
|
||||
"idle": "Idle",
|
||||
"na": "N/A",
|
||||
"resolved": "Resolved",
|
||||
"responseTime": "Response time"
|
||||
"responseTime": "Response time",
|
||||
"state": "State",
|
||||
"uptime": "Uptime",
|
||||
"health": "Health",
|
||||
"restarts": "Restarts"
|
||||
},
|
||||
"form": {
|
||||
"autocomplete": {
|
||||
@@ -203,6 +209,7 @@
|
||||
"uptime": "Uptime",
|
||||
"pagespeed": "Pagespeed",
|
||||
"infrastructure": "Infrastructure",
|
||||
"docker": "Docker",
|
||||
"notifications": "Notifications",
|
||||
"tags": "Tags",
|
||||
"proxies": "Proxies",
|
||||
@@ -211,7 +218,13 @@
|
||||
"statusPages": "Status pages",
|
||||
"maintenance": "Maintenance",
|
||||
"logs": "Logs",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"groups": {
|
||||
"monitoring": "Monitoring",
|
||||
"alerting": "Alerting",
|
||||
"reporting": "Reporting",
|
||||
"configuration": "Configuration"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Support",
|
||||
@@ -918,15 +931,56 @@
|
||||
"description": "When monitors go down or recover, incidents appear here with timing, monitor details and resolution history."
|
||||
}
|
||||
},
|
||||
"docker": {
|
||||
"header": {
|
||||
"title": "Docker monitors",
|
||||
"description": "Monitor your Docker hosts and every container running on them."
|
||||
},
|
||||
"fallback": {
|
||||
"title": "No Docker monitors yet",
|
||||
"description": "Add a Docker host to monitor its containers.",
|
||||
"actionButton": "Create a monitor"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"host": "Host",
|
||||
"containers": "Containers"
|
||||
},
|
||||
"containersRunning": "{{running}} of {{total}} running"
|
||||
},
|
||||
"container": {
|
||||
"header": {
|
||||
"title": "Container details",
|
||||
"description": "State, resource usage, and logs for this container."
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"logs": "Logs"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"statBoxes": {
|
||||
"containers": "Containers",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"unhealthy": "Unhealthy"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"container": "Containers",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"infrastructure": {
|
||||
"charts": {
|
||||
"labels": {
|
||||
"cpu": "CPU usage",
|
||||
"disk": "Disk usage",
|
||||
"memory": "Memory usage",
|
||||
"netBytesRecv": "{{name}} - Bytes Received",
|
||||
"netBytesSent": "{{name}} - Bytes Sent",
|
||||
"loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.",
|
||||
"loopbackOnlyNotice": "Capture is only seeing container network interfaces, and none of them are carrying real traffic. This is normal when Capture runs inside a container that can only observe its own network namespace — interfaces with host-like names (eth0, ens3) are the container's virtual interfaces, not the host's. Run Capture directly on the host to see real network activity.",
|
||||
"temp": "CPU temperature"
|
||||
}
|
||||
},
|
||||
@@ -1333,6 +1387,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
"monitoring": "Monitoring",
|
||||
"email": "Email",
|
||||
"integrations": "Integrations",
|
||||
"data": "Data"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Display timezone",
|
||||
@@ -1390,7 +1451,7 @@
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"title": "Monitor IP/URL on Status Page",
|
||||
"title": "Monitor IP/URL on status page",
|
||||
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
|
||||
"option": {
|
||||
"showURL": {
|
||||
@@ -1422,7 +1483,7 @@
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Global Thresholds",
|
||||
"title": "Global thresholds",
|
||||
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
|
||||
"option": {
|
||||
"cpu": {
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"statusPages": "Páginas de estado",
|
||||
"maintenance": "Mantenimiento",
|
||||
"logs": "Registros",
|
||||
"settings": "Configuración"
|
||||
"settings": "Configuración",
|
||||
"groups": {
|
||||
"monitoring": "Monitorización",
|
||||
"alerting": "Alertas",
|
||||
"reporting": "Informes",
|
||||
"configuration": "Configuración"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Soporte",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
"monitoring": "Monitorización",
|
||||
"email": "Correo electrónico",
|
||||
"integrations": "Integraciones",
|
||||
"data": "Datos"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Zona horaria de visualización",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"statusPages": "Tilasivut",
|
||||
"maintenance": "Huolto",
|
||||
"logs": "Lokit",
|
||||
"settings": "Asetukset"
|
||||
"settings": "Asetukset",
|
||||
"groups": {
|
||||
"monitoring": "Valvonta",
|
||||
"alerting": "Hälytykset",
|
||||
"reporting": "Raportointi",
|
||||
"configuration": "Määritykset"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Tuki",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "Yleiset",
|
||||
"monitoring": "Valvonta",
|
||||
"email": "Sähköposti",
|
||||
"integrations": "Integraatiot",
|
||||
"data": "Tiedot"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Näytettävä aikavyöhyke",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"statusPages": "Pages de statut",
|
||||
"maintenance": "Maintenance",
|
||||
"logs": "Journaux",
|
||||
"settings": "Paramètres"
|
||||
"settings": "Paramètres",
|
||||
"groups": {
|
||||
"monitoring": "Surveillance",
|
||||
"alerting": "Alertes",
|
||||
"reporting": "Rapports",
|
||||
"configuration": "Configuration"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Support",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "Général",
|
||||
"monitoring": "Surveillance",
|
||||
"email": "E-mail",
|
||||
"integrations": "Intégrations",
|
||||
"data": "Données"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Fuseau horaire d'affichage",
|
||||
|
||||
@@ -193,7 +193,13 @@
|
||||
"statusPages": "Pagine di stato",
|
||||
"maintenance": "Manutenzione",
|
||||
"logs": "Log",
|
||||
"settings": "Impostazioni"
|
||||
"settings": "Impostazioni",
|
||||
"groups": {
|
||||
"monitoring": "Monitoraggio",
|
||||
"alerting": "Avvisi",
|
||||
"reporting": "Report",
|
||||
"configuration": "Configurazione"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Supporto",
|
||||
@@ -1237,6 +1243,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "Generale",
|
||||
"monitoring": "Monitoraggio",
|
||||
"email": "Email",
|
||||
"integrations": "Integrazioni",
|
||||
"data": "Dati"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Fuso orario di visualizzazione",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"statusPages": "ステータスページ",
|
||||
"maintenance": "メンテナンス",
|
||||
"logs": "ログ",
|
||||
"settings": "設定"
|
||||
"settings": "設定",
|
||||
"groups": {
|
||||
"monitoring": "監視",
|
||||
"alerting": "アラート",
|
||||
"reporting": "レポート",
|
||||
"configuration": "設定"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "サポート",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "一般",
|
||||
"monitoring": "監視",
|
||||
"email": "メール",
|
||||
"integrations": "連携",
|
||||
"data": "データ"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "表示タイムゾーン",
|
||||
|
||||
@@ -214,7 +214,13 @@
|
||||
"settings": "Ustawienia",
|
||||
"statusPages": "Strony statusu",
|
||||
"tags": "Tagi",
|
||||
"uptime": "Czas działania"
|
||||
"uptime": "Czas działania",
|
||||
"groups": {
|
||||
"monitoring": "Monitorowanie",
|
||||
"alerting": "Alerty",
|
||||
"reporting": "Raporty",
|
||||
"configuration": "Konfiguracja"
|
||||
}
|
||||
},
|
||||
"starPrompt": {
|
||||
"description": "Zobacz najnowsze wydania i pomóż rozwijać społeczność na GitHub",
|
||||
@@ -1207,6 +1213,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "Ogólne",
|
||||
"monitoring": "Monitorowanie",
|
||||
"email": "E-mail",
|
||||
"integrations": "Integracje",
|
||||
"data": "Dane"
|
||||
},
|
||||
"form": {
|
||||
"about": {
|
||||
"developedBy": "Opracowane przez Bluewave Labs",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"statusPages": "Pagina de status",
|
||||
"maintenance": "Manutenção",
|
||||
"logs": "Registros",
|
||||
"settings": "Configurações"
|
||||
"settings": "Configurações",
|
||||
"groups": {
|
||||
"monitoring": "Monitoramento",
|
||||
"alerting": "Alertas",
|
||||
"reporting": "Relatórios",
|
||||
"configuration": "Configuração"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Suporte",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "Geral",
|
||||
"monitoring": "Monitoramento",
|
||||
"email": "E-mail",
|
||||
"integrations": "Integrações",
|
||||
"data": "Dados"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Fuso horário de exibição",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"statusPages": "Страницы статуса",
|
||||
"maintenance": "Обслуживание",
|
||||
"logs": "Логи",
|
||||
"settings": "Настройки"
|
||||
"settings": "Настройки",
|
||||
"groups": {
|
||||
"monitoring": "Мониторинг",
|
||||
"alerting": "Оповещения",
|
||||
"reporting": "Отчёты",
|
||||
"configuration": "Конфигурация"
|
||||
}
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "Поддержка",
|
||||
@@ -1141,6 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "Общие",
|
||||
"monitoring": "Мониторинг",
|
||||
"email": "Электронная почта",
|
||||
"integrations": "Интеграции",
|
||||
"data": "Данные"
|
||||
},
|
||||
"form": {
|
||||
"timezone": {
|
||||
"title": "Часовой пояс отображения",
|
||||
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user