From 171376f8970f8e461e6a5a555244120348b268b3 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Mon, 2 Nov 2026 21:22:01 +0100 Subject: [PATCH] (feature): add full screen and an idle bubbles screen saver --- CHANGELOG.md | 27 +- CONTRIBUTING.md | 2 +- README.md | 62 +-- WHATSNEW.md | 7 + data/com.maks_it.clusterconsole.metainfo.xml | 6 + docs/features.md | 26 +- packaging/flathub/manifest.md | 2 +- packaging/flathub/sources.md | 2 +- src/Directory.Build.props | 2 +- .../Configuration.cs | 4 + .../ScreenSaverSettings.cs | 16 + .../ConfigurationFileServiceTests.cs | 46 ++ .../Controls/BubblesScreenSaverControl.cs | 413 ++++++++++++++++++ .../Services/LayoutOriginators.cs | 12 +- .../Services/ScreenSaverController.cs | 167 +++++++ .../ScreenSaverSettingsViewModel.cs | 55 +++ .../ViewModels/Shell/MainViewModel.cs | 35 ++ .../Windows/MainWindow.axaml | 16 + .../Windows/MainWindow.axaml.cs | 74 ++++ .../Windows/ScreenSaverSettingsWindow.axaml | 33 ++ .../ScreenSaverSettingsWindow.axaml.cs | 16 + 21 files changed, 968 insertions(+), 55 deletions(-) create mode 100644 src/MaksIT.ClusterConsole.Shared/ScreenSaverSettings.cs create mode 100644 src/MaksIT.ClusterConsole.UI/Controls/BubblesScreenSaverControl.cs create mode 100644 src/MaksIT.ClusterConsole.UI/Services/ScreenSaverController.cs create mode 100644 src/MaksIT.ClusterConsole.UI/ViewModels/ScreenSaverSettingsViewModel.cs create mode 100644 src/MaksIT.ClusterConsole.UI/Windows/ScreenSaverSettingsWindow.axaml create mode 100644 src/MaksIT.ClusterConsole.UI/Windows/ScreenSaverSettingsWindow.axaml.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 14d1f9d..c4b8686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.9.2] - 2026-11-02 + +### Added + +- **View -> Full screen** or **F11** fills the display; **Escape** leaves full screen. Full screen is not restored on the next launch. +- **Settings -> Screen saver** enables a bubbles overlay after idle minutes while full screen. The console stays readable under the bubbles. Any mouse or key press clears the overlay without leaving full screen. + +### Changed + +- AppStream screenshots stay on tag `v0.8.6`, where those images are stored. The `0.9.2` release entry points at this version's changelog. + ## [0.9.1] - 2026-10-31 ### Added @@ -64,7 +75,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- When Settings → AI is on, Chat sits on the cluster overview beside Errors and Warnings. Analyze issues asks which objects are still failing and which events are leftover after the pods recovered. Those warnings and errors go with the question. +- When Settings -> AI is on, Chat sits on the cluster overview beside Errors and Warnings. Analyze issues asks which objects are still failing and which events are leftover after the pods recovered. Those warnings and errors go with the question. ### Changed @@ -133,8 +144,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - The navigator adds admission policies, CSI objects, API services, flow schemas, priority levels, and dynamic resource allocation types. Custom resource tables use the CRD `additionalPrinterColumns`. - A list can send a label selector with the API request. - YAML apply falls back to create or replace when the API server rejects an apply patch. -- Settings → AI sets the local Ollama endpoint and model. Chat stays read-only unless the agent is enabled; restart, pod delete, scale, and apply then wait for a confirmation. -- Help → About shows the product, license, and contact. +- Settings -> AI sets the local Ollama endpoint and model. Chat stays read-only unless the agent is enabled; restart, pod delete, scale, and apply then wait for a confirmation. +- Help -> About shows the product, license, and contact. ### Fixed @@ -161,7 +172,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- Help → Logs shows the app log and crash reports, and an unhandled error opens a copyable debug window instead of closing the app. +- Help -> Logs shows the app log and crash reports, and an unhandled error opens a copyable debug window instead of closing the app. - YAML view, volume files, and ConfigMap/Secret values highlight as JSON or YAML. A data key or file name uses its extension (`.json`, `.yaml`, `.yml`); a key without one stays plain. The resource YAML tab is always YAML. - Services table includes Status: Active when the load-balancer address is assigned, Pending when none is set, and Unreachable when a BGP or requested address is not the one in `status.loadBalancer.ingress` (or Cilium IPAM reports it unsatisfied). @@ -228,7 +239,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Applications** table shows live **CPU** (% of cluster allocatable) and **Memory** (k8s `Mi`/`Gi`) from summed pod metrics when metrics-server is available. - **Deployments**, **StatefulSets**, and **DaemonSets** tables include **CPU** and **Memory** columns (millicores / `MiB`, summed from owned pods). - Applications metric **tooltips**: CPU in millicores; memory as `MiB` plus rounded `MB`. -- Applications pod attribution matches instance labels, direct workload owners, and **ReplicaSet → Deployment** owners. +- Applications pod attribution matches instance labels, direct workload owners, and **ReplicaSet -> Deployment** owners. - Transient Kubernetes API errors (for example HTTP/2 `ResponseEnded`) retry automatically; the client uses HTTP/1.1 for list traffic. ### Changed @@ -283,12 +294,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Status-bar messages for port-forward start, stop, restore, and failure. - Local / Remote labels on the port-forward bar so the two port fields are explicit. -- Rebind the host port of an existing forward from **Network → Port Forwarding** (Local field + Rebind). -- Double-click a live port-forward in **Network → Port Forwarding** to open `http://127.0.0.1:{local}` in the default browser. +- Rebind the host port of an existing forward from **Network -> Port Forwarding** (Local field + Rebind). +- Double-click a live port-forward in **Network -> Port Forwarding** to open `http://127.0.0.1:{local}` in the default browser. ### Changed -- Active port-forwards are listed in **Network → Port Forwarding** (stop from the table). They are no longer shown in the status bar. +- Active port-forwards are listed in **Network -> Port Forwarding** (stop from the table). They are no longer shown in the status bar. - Resource table footer, YAML/Data edit bars, and workload limit apply buttons show only for a selected row that can use them. Empty toolbars are hidden. - Enabled port-forwards are saved in `appsettings.json` (`Configuration:PortForwards`) and restored when a cluster reconnects. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d023acf..c911d1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ Store listing copy: ### System requirements (Properties) -Partner Center → **Properties** → **System requirements**. A blank cell stays unset. Minimum is what the Store may warn on; Recommended does not warn. +Partner Center -> **Properties** -> **System requirements**. A blank cell stays unset. Minimum is what the Store may warn on; Recommended does not warn. The app is a win-x64 desktop console (tables, YAML, terminal). It does not use a camera, microphone, radio, gamepad, or a specific GPU. diff --git a/README.md b/README.md index 1dc50e7..5a694a4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - Your kubeconfig and its RBAC are the access path. Click a catalog row to connect that context in this app. The radio writes kubectl `current-context` for the command line. This app does not read that field. Several contexts can stay connected. - Helm, Dapr, and Longhorn screens view and operate what is already installed. They do not install those systems. -- **Cloud…** adds Google, Azure, Amazon, Oracle, DigitalOcean, or IBM by running that provider's CLI. **S3 Browser** opens a Longhorn backup target. +- **Cloud...** adds Google, Azure, Amazon, Oracle, DigitalOcean, or IBM by running that provider's CLI. **S3 Browser** opens a Longhorn backup target. - Chat is optional local [Ollama](https://ollama.com). It is read-only by default. With changes allowed, each restart, pod delete, scale, and apply waits for Approve. - Windows, Linux, and macOS. [Apache 2.0](LICENSE.md). @@ -48,28 +48,29 @@ If you find this project useful, please consider supporting its development: Each line links to the screen notes in [docs/features.md](docs/features.md). -- [Cluster overview](docs/features.md#cluster-overview) — version, node count, CPU, memory, and pod charts, plus errors and warnings. -- [Applications](docs/features.md#applications) — one row per app instance and namespace, with ready, CPU, and memory. -- [Pods](docs/features.md#pods) — logs, shell, attach, a debug container, and port-forward. -- [Chat](docs/features.md#chat) — local Ollama on the overview or the selection. Read-only by default. -- [Volume files](docs/features.md#volume-files) — browse, edit, download, and upload files on a PV or claim. -- [Dapr](docs/features.md#dapr) — components, configurations, subscriptions, sidecars, and the control plane. -- [Port forwarding](docs/features.md#port-forwarding) — saved tunnels that restore on reconnect and open in the browser. -- [Connections](docs/features.md#connections) — the kubeconfig on disk, **Add…**, and **Cloud…** for six providers. -- [Navigator](docs/features.md#navigator) — cluster through custom resources. Longhorn and CloudNativePG appear when those APIs are installed. -- [Longhorn](docs/features.md#longhorn) — attach, detach, recurring jobs, backups, and S3 Browser. Does not install Longhorn. -- [Tables](docs/features.md#tables) — watch, search, label selector, and a footer action on the selection. -- [YAML](docs/features.md#yaml) — view, apply, create, and delete. -- [Workloads](docs/features.md#workloads) — scale, restart, rollout, and a CronJob trigger. -- [Nodes](docs/features.md#nodes) — cordon, uncordon, and drain, with a preview of which pods move. -- [Config](docs/features.md#config) — ConfigMaps, Secrets, quotas, autoscaling, and admission. -- [Network](docs/features.md#network) — Services, ingress, and policies. A load balancer is Active, Pending, or Unreachable. -- [Storage](docs/features.md#storage) — resize a claim, change reclaim policy, and browse volume files. -- [Access](docs/features.md#access) — approve a certificate signing request, and create a ServiceAccount token shown once. -- [Custom resources](docs/features.md#custom-resources) — extra columns from the CRD. -- [Helm](docs/features.md#helm) — installed charts, revision history, and a values diff. Install and upgrade stay on the Helm CLI. -- [Metrics](docs/features.md#metrics) — CPU and memory from metrics-server. -- [Help](docs/features.md#help) — Hint on the kind, the app log, a copyable crash window, and What's New. +- [Cluster overview](docs/features.md#cluster-overview) - version, node count, CPU, memory, and pod charts, plus errors and warnings. +- [Applications](docs/features.md#applications) - one row per app instance and namespace, with ready, CPU, and memory. +- [Pods](docs/features.md#pods) - logs, shell, attach, a debug container, and port-forward. +- [Chat](docs/features.md#chat) - local Ollama on the overview or the selection. Read-only by default. +- [Full screen and screen saver](docs/features.md#full-screen-and-screen-saver) - F11 full screen, and optional idle bubbles while full screen. +- [Volume files](docs/features.md#volume-files) - browse, edit, download, and upload files on a PV or claim. +- [Dapr](docs/features.md#dapr) - components, configurations, subscriptions, sidecars, and the control plane. +- [Port forwarding](docs/features.md#port-forwarding) - saved tunnels that restore on reconnect and open in the browser. +- [Connections](docs/features.md#connections) - the kubeconfig on disk, **Add...**, and **Cloud...** for six providers. +- [Navigator](docs/features.md#navigator) - cluster through custom resources. Longhorn and CloudNativePG appear when those APIs are installed. +- [Longhorn](docs/features.md#longhorn) - attach, detach, recurring jobs, backups, and S3 Browser. Does not install Longhorn. +- [Tables](docs/features.md#tables) - watch, search, label selector, and a footer action on the selection. +- [YAML](docs/features.md#yaml) - view, apply, create, and delete. +- [Workloads](docs/features.md#workloads) - scale, restart, rollout, and a CronJob trigger. +- [Nodes](docs/features.md#nodes) - cordon, uncordon, and drain, with a preview of which pods move. +- [Config](docs/features.md#config) - ConfigMaps, Secrets, quotas, autoscaling, and admission. +- [Network](docs/features.md#network) - Services, ingress, and policies. A load balancer is Active, Pending, or Unreachable. +- [Storage](docs/features.md#storage) - resize a claim, change reclaim policy, and browse volume files. +- [Access](docs/features.md#access) - approve a certificate signing request, and create a ServiceAccount token shown once. +- [Custom resources](docs/features.md#custom-resources) - extra columns from the CRD. +- [Helm](docs/features.md#helm) - installed charts, revision history, and a values diff. Install and upgrade stay on the Helm CLI. +- [Metrics](docs/features.md#metrics) - CPU and memory from metrics-server. +- [Help](docs/features.md#help) - Hint on the kind, the app log, a copyable crash window, and What's New. ## Requirements @@ -122,14 +123,17 @@ Operator layout, open clusters, port-forwards, and Chat settings are stored in ` | Key | Role | |-----|------| -| `AiEnabled` | Chat tab. Off until enabled under Settings → AI | +| `AiEnabled` | Chat tab. Off until enabled under Settings -> AI | | `AiAgentEnabled` | With chat on, restart, pod delete, scale, and apply run only after you approve each one | | `OllamaEndpoint` | Local Ollama API, default `http://127.0.0.1:11434` | | `OllamaModel` | Local Ollama model, default `qwen3:8b`. A larger model (`qwen3:14b`, `qwen3:32b`, `qwen3-coder:30b`) is a better fit once changes are allowed | -| `OllamaTimeoutSeconds` | How long one Chat reply may run, in seconds. Default 240. Settings → AI shows this as Timeout | +| `OllamaTimeoutSeconds` | How long one Chat reply may run, in seconds. Default 240. Settings -> AI shows this as Timeout | +| `ScreenSaver` | Bubbles overlay while full screen is idle. `Enabled` (off by default) and `IdleMinutes` (default 5). Settings -> Screen saver | | `PortForwards` | Enabled localhost forwards; restored on reconnect | | `Layout` | Window, panes, last navigator item, per-cluster tables | +**View -> Full screen** or **F11** fills the display. **Escape** leaves full screen. When the screen saver is on, idle only while full screen shows translucent bubbles over the live UI; the first mouse or key press clears the bubbles and keeps full screen. Full screen is not restored on the next launch. + Chat stays read-only until **Allow the assistant to change the cluster** is on. Those changes still wait for Approve. The model box accepts any local Ollama model; larger models handle restart, scale, delete, and apply more reliably than `qwen3:8b`. ## FAQ @@ -141,13 +145,13 @@ Yes. It is a native desktop Kubernetes GUI (operator console) for Windows, Linux Yes. `KUBECONFIG` or `~/.kube/config`. RBAC is whatever that identity already has. Clicking a catalog row connects that context in this app. The radio only writes kubectl `current-context` so your command line can use the same cluster. This app does not read that field. **Does Chat send cluster data to a cloud AI?** -No. Chat is optional [Ollama](https://ollama.com) on the same machine. The model is whatever you set under Settings → AI. By default the tools only read issues, YAML, logs, and events. Turn on **Allow the assistant to change the cluster** and the same chat can restart, delete a pod, scale, or apply YAML, each after you approve it. A larger local model is the better choice for that. +No. Chat is optional [Ollama](https://ollama.com) on the same machine. The model is whatever you set under Settings -> AI. By default the tools only read issues, YAML, logs, and events. Turn on **Allow the assistant to change the cluster** and the same chat can restart, delete a pod, scale, or apply YAML, each after you approve it. A larger local model is the better choice for that. **Can Flatpak run a cloud exec plugin?** Yes. **Download CLI** installs gcloud, az, aws, oci, doctl, ibmcloud, or kubelogin into the app. The sandbox starts that copy, including the exec plugin a kubeconfig names. A CLI installed on the host is not visible inside the sandbox. A certificate or token needs no CLI. **Does it have a Cloud window and an S3 browser?** -Yes. **Cloud…** adds Google, Azure, Amazon, Oracle, DigitalOcean, or IBM by running that provider's CLI, which writes the API server, certificate, and login. **S3 Browser**, under Longhorn, opens the default backup target and any other S3 targets. +Yes. **Cloud...** adds Google, Azure, Amazon, Oracle, DigitalOcean, or IBM by running that provider's CLI, which writes the API server, certificate, and login. **S3 Browser**, under Longhorn, opens the default backup target and any other S3 targets. **Can it install a cluster, Helm charts, Dapr, or Longhorn?** No. It talks to an existing Kubernetes API. Helm, Dapr, and Longhorn screens view and operate objects that are already there. They do not install those systems. Adding a Longhorn backup target writes that target and its credential secret. S3 Browser opens a target those credentials can already reach. @@ -165,11 +169,11 @@ Or `dotnet test MaksIT.ClusterConsole.Tests` from `src/`. Tests use kubeconfig f ## Scope -Desktop operator console for the Kubernetes API. Not a CLI, cluster installer, GitOps, or secret-management system. Helm, Dapr, and Longhorn screens view and operate objects already in the cluster. They do not install those systems. Adding a Longhorn backup target writes that target and its credential secret. **Cloud…** writes a kubeconfig context by running a provider CLI. **S3 Browser** opens a Longhorn backup target. +Desktop operator console for the Kubernetes API. Not a CLI, cluster installer, GitOps, or secret-management system. Helm, Dapr, and Longhorn screens view and operate objects already in the cluster. They do not install those systems. Adding a Longhorn backup target writes that target and its credential secret. **Cloud...** writes a kubeconfig context by running a provider CLI. **S3 Browser** opens a Longhorn backup target. ## License -Apache 2.0 — see [LICENSE.md](LICENSE.md). +Apache 2.0 - see [LICENSE.md](LICENSE.md). © Maksym Sadovnychyy (MAKS-IT) diff --git a/WHATSNEW.md b/WHATSNEW.md index f522e81..0dd627a 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -2,6 +2,13 @@ Short notes shown in the app after an upgrade. The full history, including maintainer notes, is [CHANGELOG.md](CHANGELOG.md). +## [Unreleased] + +## [0.9.2] - 2026-11-02 + +- View -> Full screen or F11 fills the display. Escape leaves full screen. +- Settings -> Screen saver can show translucent bubbles after idle while full screen, so the console stays visible on a wall display. + ## [0.9.1] - 2026-10-31 - The cluster catalog can collapse to a Connections rail, the same way the details pane collapses. The choice is kept. diff --git a/data/com.maks_it.clusterconsole.metainfo.xml b/data/com.maks_it.clusterconsole.metainfo.xml index 6d26398..8e317b5 100644 --- a/data/com.maks_it.clusterconsole.metainfo.xml +++ b/data/com.maks_it.clusterconsole.metainfo.xml @@ -65,6 +65,12 @@ + + https://github.com/MAKS-IT-COM/maksit-cluster-console/blob/v0.9.2/CHANGELOG.md + +

Full screen with F11. Optional idle bubbles screen saver while full screen.

+
+
https://github.com/MAKS-IT-COM/maksit-cluster-console/blob/v0.9.1/CHANGELOG.md diff --git a/docs/features.md b/docs/features.md index 5cc60cb..4c9a37e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -4,11 +4,11 @@ Screen notes for [MaksIT Cluster Console](../README.md). The README lists each a ## Cluster overview -Click a catalog row to open that context. A green dot is a live session in this app. The radio is labeled **kubectl context** and only writes kubectl `current-context` for the command line. This app does not follow that field. Until a row is open, the window stays on the catalog and asks you to select a cluster. Open clusters stay connected until you disconnect them. **Connections…** and **Reload kubeconfig** sit under the catalog. The catalog can collapse to a narrow rail labeled Connections; opening it restores the previous width. The choice is saved with the layout. The status line counts contexts in the kubeconfig and how many are connected here. +Click a catalog row to open that context. A green dot is a live session in this app. The radio is labeled **kubectl context** and only writes kubectl `current-context` for the command line. This app does not follow that field. Until a row is open, the window stays on the catalog and asks you to select a cluster. Open clusters stay connected until you disconnect them. **Connections...** and **Reload kubeconfig** sit under the catalog. The catalog can collapse to a narrow rail labeled Connections; opening it restores the previous width. The choice is saved with the layout. The status line counts contexts in the kubeconfig and how many are connected here. ![Cluster catalog](../assets/screenshots/welcome.png) -Overview names the Kubernetes version and how many nodes are in the cluster. **Cluster** and **Nodes** switch the charts. CPU, memory, and pods show usage, requests, limits, allocatable capacity, and capacity from metrics-server. **Resource limits** lists containers you can patch, and **Apply selected** writes the chosen CPU and memory against node capacity. Under the charts, Errors and Warnings list node, pod, and event problems, a LoadBalancer Service that is Pending or Unreachable, and a PersistentVolumeClaim that is Pending. When nothing is wrong, that pane says no issues were found. When Settings → AI is on, Chat sits on the right with the model name, **Analyze issues**, and **Clear**. +Overview names the Kubernetes version and how many nodes are in the cluster. **Cluster** and **Nodes** switch the charts. CPU, memory, and pods show usage, requests, limits, allocatable capacity, and capacity from metrics-server. **Resource limits** lists containers you can patch, and **Apply selected** writes the chosen CPU and memory against node capacity. Under the charts, Errors and Warnings list node, pod, and event problems, a LoadBalancer Service that is Pending or Unreachable, and a PersistentVolumeClaim that is Pending. When nothing is wrong, that pane says no issues were found. When Settings -> AI is on, Chat sits on the right with the model name, **Analyze issues**, and **Clear**. ![Cluster overview](../assets/screenshots/overview.png) @@ -31,9 +31,15 @@ Name, Namespace, Age, Ready, Restarts, Status, and Node, plus CPU and Memory whe ![Pod logs](../assets/screenshots/pods-logs.png) +## Full screen and screen saver + +**View -> Full screen** or **F11** fills the display. **Escape** leaves full screen. Full screen is not restored on the next launch. + +**Settings -> Screen saver** turns on translucent bubbles after a chosen idle interval, only while full screen. The console stays readable under the bubbles. The first mouse or key press clears the overlay and keeps full screen. Desk use is unchanged until you enter full screen. + ## Chat -Settings → AI opens **Local Ollama**. **Enable chat** turns Chat on, on a resource and on the cluster overview. The endpoint and model are any local [Ollama](https://ollama.com) model you already pulled. The default model is `qwen3:8b`. Reading the cluster is enough with that model. Repairs work better with `qwen3:14b` or larger (`qwen3:32b`, `qwen3-coder:30b`). **Timeout** is how long one reply may run, in seconds. The default is 240. +Settings -> AI opens **Local Ollama**. **Enable chat** turns Chat on, on a resource and on the cluster overview. The endpoint and model are any local [Ollama](https://ollama.com) model you already pulled. The default model is `qwen3:8b`. Reading the cluster is enough with that model. Repairs work better with `qwen3:14b` or larger (`qwen3:32b`, `qwen3-coder:30b`). **Timeout** is how long one reply may run, in seconds. The default is 240. ![AI settings](../assets/screenshots/ai-settings.png) @@ -68,14 +74,14 @@ A sidecar is the `daprd` container in an application pod. This list is those pod ## Port forwarding -**Network → Port Forwarding** lists each tunnel as `localhost:{port}`, the namespace, the pod, and the local and remote ports. Tunnels persist, restore on reconnect, and retarget a running pod. Double-click an active forward to open `http://127.0.0.1:{port}/`. **Rebind** changes the local port. The same local port, remote port, and **Port-forward** sit on a pod or Deployment footer. Details are Overview, YAML, and Chat. +**Network -> Port Forwarding** lists each tunnel as `localhost:{port}`, the namespace, the pod, and the local and remote ports. Tunnels persist, restore on reconnect, and retarget a running pod. Double-click an active forward to open `http://127.0.0.1:{port}/`. **Rebind** changes the local port. The same local port, remote port, and **Port-forward** sit on a pod or Deployment footer. Details are Overview, YAML, and Chat. ![Port forwarding](../assets/screenshots/port-forwarding.png) ## Connections -**Connections…** opens **Kubeconfig connections** for the file on disk. Each row is a context and its API server. The radio is **kubectl context**. A green dot is a live session in this app. The details pane shows the namespace, cluster name, server, whether TLS verify is skipped, whether CA data is present, the user, and how that user authenticates. **Add…** writes a context you fill in: bearer token, client certificate, pasted k3s certificate data, username and password, or an exec plugin when you already know the API server. **Cloud…** is a separate window for Google Kubernetes Engine, Azure Kubernetes Service, Amazon EKS, Oracle Kubernetes, DigitalOcean Kubernetes, and IBM Cloud Kubernetes. Sign in with that provider's CLI first. The window runs `gcloud container clusters get-credentials`, `az aks get-credentials`, `aws eks update-kubeconfig`, or the Oracle, DigitalOcean, or IBM command, and that CLI writes the API server, certificate, and login. The program must be on PATH. On Linux, **Download CLI** in that window installs gcloud, az, aws, oci, doctl, ibmcloud, or kubelogin into the app and puts them on PATH. The Flatpak sandbox starts that copy, including an exec plugin the kubeconfig names, so a token refresh runs inside the sandbox. A login already saved in the home directory is reused. Published checksums are checked. The AWS CLI zip is checked with the AWS CLI public key. A CLI installed on the host is not visible to the sandbox. **Connect** opens the selected context here. **Delete** removes it. **Cleanup unused cluster/user on delete** also drops a cluster or user entry that nothing else references. This app ignores kubectl `current-context` and uses the row you click. Several contexts stay connected until you disconnect them, while kubectl still has one current context. **Reload kubeconfig** rereads the file. +**Connections...** opens **Kubeconfig connections** for the file on disk. Each row is a context and its API server. The radio is **kubectl context**. A green dot is a live session in this app. The details pane shows the namespace, cluster name, server, whether TLS verify is skipped, whether CA data is present, the user, and how that user authenticates. **Add...** writes a context you fill in: bearer token, client certificate, pasted k3s certificate data, username and password, or an exec plugin when you already know the API server. **Cloud...** is a separate window for Google Kubernetes Engine, Azure Kubernetes Service, Amazon EKS, Oracle Kubernetes, DigitalOcean Kubernetes, and IBM Cloud Kubernetes. Sign in with that provider's CLI first. The window runs `gcloud container clusters get-credentials`, `az aks get-credentials`, `aws eks update-kubeconfig`, or the Oracle, DigitalOcean, or IBM command, and that CLI writes the API server, certificate, and login. The program must be on PATH. On Linux, **Download CLI** in that window installs gcloud, az, aws, oci, doctl, ibmcloud, or kubelogin into the app and puts them on PATH. The Flatpak sandbox starts that copy, including an exec plugin the kubeconfig names, so a token refresh runs inside the sandbox. A login already saved in the home directory is reused. Published checksums are checked. The AWS CLI zip is checked with the AWS CLI public key. A CLI installed on the host is not visible to the sandbox. **Connect** opens the selected context here. **Delete** removes it. **Cleanup unused cluster/user on delete** also drops a cluster or user entry that nothing else references. This app ignores kubectl `current-context` and uses the row you click. Several contexts stay connected until you disconnect them, while kubectl still has one current context. **Reload kubeconfig** rereads the file. ![Connections](../assets/screenshots/connections.png) @@ -87,7 +93,7 @@ Cluster, Nodes, Applications, Workloads, Config, Network, Storage, Namespaces, E Longhorn is its own navigator group when that API is installed: **Volumes**, **Nodes**, **Recurring jobs**, **Backups**, and **S3 Browser**. These are Longhorn objects, not the Kubernetes node list and not a generic PersistentVolume. The screens view and operate Longhorn. They do not install it. -**Volumes** matches the Longhorn UI. **State** is attached or detached. Attached is green. Degraded or faulted is shown with the state when a replica is unhealthy. **Name** is the technical Longhorn name, usually `pvc-` and an id. **Volume** is the PersistentVolumeClaim name, the name you gave the disk. **Node** is the Longhorn node the volume is attached to, and the tip lists pods using it. **Size** is the requested size. **Actual Size** is the space the replicas use. **Created** is when Longhorn created the volume. **Data Engine** is v1 or v2. **PV/PVC** is Bound, Available, or Released, and the tip names the PV and PVC. **Namespace** is the claim's namespace, not `longhorn-system`. **Schedule** is the recurring jobs on that volume. **Last Backup At** is the time of the latest backup. **Attach** and **Detach** are in the footer. Attach picks a Ready node. Detach can force a leftover ticket off. **Schedule backup…** creates a periodic backup job and labels the selected volumes. The schedule is five cron fields, and the cron line stays visible. A degraded volume that is still attached, or a failed replica of it, beside a new detached volume for the same claim or one restored from its backup, is called out on the overview. Detach the leftover, then attach the new one. Do not delete the new volume. +**Volumes** matches the Longhorn UI. **State** is attached or detached. Attached is green. Degraded or faulted is shown with the state when a replica is unhealthy. **Name** is the technical Longhorn name, usually `pvc-` and an id. **Volume** is the PersistentVolumeClaim name, the name you gave the disk. **Node** is the Longhorn node the volume is attached to, and the tip lists pods using it. **Size** is the requested size. **Actual Size** is the space the replicas use. **Created** is when Longhorn created the volume. **Data Engine** is v1 or v2. **PV/PVC** is Bound, Available, or Released, and the tip names the PV and PVC. **Namespace** is the claim's namespace, not `longhorn-system`. **Schedule** is the recurring jobs on that volume. **Last Backup At** is the time of the latest backup. **Attach** and **Detach** are in the footer. Attach picks a Ready node. Detach can force a leftover ticket off. **Schedule backup...** creates a periodic backup job and labels the selected volumes. The schedule is five cron fields, and the cron line stays visible. A degraded volume that is still attached, or a failed replica of it, beside a new detached volume for the same claim or one restored from its backup, is called out on the overview. Detach the leftover, then attach the new one. Do not delete the new volume. ![Longhorn volumes](../assets/screenshots/longhorn-volumes.png) @@ -96,7 +102,7 @@ Longhorn is its own navigator group when that API is installed: **Volumes**, **N ![Longhorn nodes](../assets/screenshots/longhorn-nodes.png) -**Recurring jobs** is a Longhorn schedule. Columns are State, Name, Task, Schedule, Last Successful Run, Retain, and Groups. **State** is Running while a run is in progress, Failed when the latest run failed, and Idle when nothing is running. **Schedule** is when it runs. **Retain** is how many copies the job keeps. Snapshot Cleanup and Filesystem Trim do not use Retain. **Groups** are the Longhorn groups the job belongs to. The default group runs on volumes that have no other recurring job. **New job…** creates one. **Edit…** can change the name, which points the volumes at the new job and then removes the old one. **Run now** starts the selected job immediately, and does not start another while a run of that job is still in progress. +**Recurring jobs** is a Longhorn schedule. Columns are State, Name, Task, Schedule, Last Successful Run, Retain, and Groups. **State** is Running while a run is in progress, Failed when the latest run failed, and Idle when nothing is running. **Schedule** is when it runs. **Retain** is how many copies the job keeps. Snapshot Cleanup and Filesystem Trim do not use Retain. **Groups** are the Longhorn groups the job belongs to. The default group runs on volumes that have no other recurring job. **New job...** creates one. **Edit...** can change the name, which points the volumes at the new job and then removes the old one. **Run now** starts the selected job immediately, and does not start another while a run of that job is still in progress. The task is what the job does: @@ -109,9 +115,9 @@ The task is what the job does: ![Recurring jobs](../assets/screenshots/longhorn-backup-jobs.png) -**Backups** is one row per volume in the backup target, the same list as the Longhorn UI. Longhorn does not keep a separate Backup object for each copy. Columns are State, Volume, Last Backup, Last Backup At, Created, Size, and Target. **Volume** is the Longhorn volume name, the same string as Volumes → Name. **Last Backup** is the newest backup name. **Last Backup At** is when it was taken. **Created** is when this volume first appeared in the target. **Size** is the size of the volume that was backed up. **Target** is which backup target holds the copy. **State** is Ready, Terminating, or Error. **Delete** removes that volume's backups from the target. +**Backups** is one row per volume in the backup target, the same list as the Longhorn UI. Longhorn does not keep a separate Backup object for each copy. Columns are State, Volume, Last Backup, Last Backup At, Created, Size, and Target. **Volume** is the Longhorn volume name, the same string as Volumes -> Name. **Last Backup** is the newest backup name. **Last Backup At** is when it was taken. **Created** is when this volume first appeared in the target. **Size** is the size of the volume that was backed up. **Target** is which backup target holds the copy. **State** is Ready, Terminating, or Error. **Delete** removes that volume's backups from the target. -**S3 Browser** opens the default backup target and any other S3 targets. The target menu names the target, its URL, and how many buckets those credentials can see. **Reload**, **Add target…**, **Edit…**, and **Remove** sit on that bar. The backup bucket opens first. **New** creates a bucket. **Delete** removes a bucket and everything in it. A bucket used by a Longhorn backup target cannot be deleted. Adding a target writes the Longhorn backup target and its credential secret. Inside a bucket, **Open path** and **Filter** sit above the object list (Name, Kind, Size, Modified). The footer is **Refresh**, **Up**, **Open**, **New folder**, **Upload**, **Upload folder**, **Download**, **Delete**, **Rename**, and **Copy**. A small text object opens in its own window. +**S3 Browser** opens the default backup target and any other S3 targets. The target menu names the target, its URL, and how many buckets those credentials can see. **Reload**, **Add target...**, **Edit...**, and **Remove** sit on that bar. The backup bucket opens first. **New** creates a bucket. **Delete** removes a bucket and everything in it. A bucket used by a Longhorn backup target cannot be deleted. Adding a target writes the Longhorn backup target and its credential secret. Inside a bucket, **Open path** and **Filter** sit above the object list (Name, Kind, Size, Modified). The footer is **Refresh**, **Up**, **Open**, **New folder**, **Upload**, **Upload folder**, **Download**, **Delete**, **Rename**, and **Copy**. A small text object opens in its own window. ![S3 browser](../assets/screenshots/s3-browser.png) @@ -195,4 +201,4 @@ CPU and memory when the metrics API is available. Charts use the same metrics-se ## Help -**Hint** on a table explains that kind. Help → Logs shows the app log and crash reports. An unhandled error opens a window you can copy instead of closing the app. Help → About shows the product, license, and contact. After an upgrade, What's New lists additions since the version you last opened. +**Hint** on a table explains that kind. Help -> Logs shows the app log and crash reports. An unhandled error opens a window you can copy instead of closing the app. Help -> About shows the product, license, and contact. After an upgrade, What's New lists additions since the version you last opened. diff --git a/packaging/flathub/manifest.md b/packaging/flathub/manifest.md index e96fac5..2b36db9 100644 --- a/packaging/flathub/manifest.md +++ b/packaging/flathub/manifest.md @@ -9,7 +9,7 @@ Values for `com.maks_it.clusterconsole.yml` (or `.json`) at the top of the Flath | Homepage | [https://maks-it.com/](https://maks-it.com/) | | Project | `src/MaksIT.ClusterConsole.UI/MaksIT.ClusterConsole.UI.csproj` | | Target | `net10.0` | -| Source | Release tag on `https://github.com/MAKS-IT-COM/maksit-cluster-console` (currently `v0.9.1`) | +| Source | Release tag on `https://github.com/MAKS-IT-COM/maksit-cluster-console` (currently `v0.9.2`) | | Runtime | `org.freedesktop.Platform` | | SDK | `org.freedesktop.Sdk` | | SDK extension | `org.freedesktop.Sdk.Extension.dotnet10` | diff --git a/packaging/flathub/sources.md b/packaging/flathub/sources.md index 57ff8b3..0f3aa2a 100644 --- a/packaging/flathub/sources.md +++ b/packaging/flathub/sources.md @@ -9,7 +9,7 @@ Upstream files stay in this repository. The Flathub build installs them from the | [src/MaksIT.ClusterConsole.UI/Assets/icon.svg](../../src/MaksIT.ClusterConsole.UI/Assets/icon.svg) | Icon, installed as `share/icons/hicolor/scalable/apps/com.maks_it.clusterconsole.svg`. | | [LICENSE.md](../../LICENSE.md) | Apache-2.0. Install to `$FLATPAK_DEST/share/licenses/com.maks_it.clusterconsole/` because the filename is `LICENSE.md`. | -Screenshot URLs in the metainfo use tag `v0.8.6`, where those images are stored. The `0.9.1` release entry points at tag `v0.9.1`. On a newer release, point `` at the tag that contains the screenshots and `` at that version. +Screenshot URLs in the metainfo use tag `v0.8.6`, where those images are stored. The `0.9.2` release entry points at tag `v0.9.2`. On a newer release, point `` at the tag that contains the screenshots and `` at that version. ## Pull request diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7650115..6ed4636 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.9.1 + 0.9.2 Cluster Console Cluster Console MaksIT diff --git a/src/MaksIT.ClusterConsole.Shared/Configuration.cs b/src/MaksIT.ClusterConsole.Shared/Configuration.cs index 5ff95ac..3673460 100644 --- a/src/MaksIT.ClusterConsole.Shared/Configuration.cs +++ b/src/MaksIT.ClusterConsole.Shared/Configuration.cs @@ -48,6 +48,8 @@ public sealed class Configuration { public LayoutSettings Layout { get; set; } = new(); + public ScreenSaverSettings ScreenSaver { get; set; } = new(); + public string? WhatsNewSeenVersion { get; set; } [JsonIgnore] @@ -64,6 +66,8 @@ public sealed class Configuration { NavigatorExpanded ??= new Dictionary(StringComparer.Ordinal); Layout ??= new LayoutSettings(); Layout.Normalize(); + ScreenSaver ??= new ScreenSaverSettings(); + ScreenSaver.Ensure(); Forwards.Ensure(); Ai.Ensure(); } diff --git a/src/MaksIT.ClusterConsole.Shared/ScreenSaverSettings.cs b/src/MaksIT.ClusterConsole.Shared/ScreenSaverSettings.cs new file mode 100644 index 0000000..1617e9d --- /dev/null +++ b/src/MaksIT.ClusterConsole.Shared/ScreenSaverSettings.cs @@ -0,0 +1,16 @@ +namespace MaksIT.ClusterConsole.Shared; + +public sealed class ScreenSaverSettings { + public const int DefaultIdleMinutes = 5; + public const int MinIdleMinutes = 1; + public const int MaxIdleMinutes = 120; + + public bool Enabled { get; set; } + + public int IdleMinutes { get; set; } = DefaultIdleMinutes; + + public void Ensure() { + if (IdleMinutes < MinIdleMinutes || IdleMinutes > MaxIdleMinutes) + IdleMinutes = DefaultIdleMinutes; + } +} diff --git a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs index da75f2c..e20010a 100644 --- a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs @@ -521,4 +521,50 @@ public class ConfigurationFileServiceTests { Assert.Equal(Configuration.AllNamespaces, cfg.NamespaceFor("prod")); Assert.Equal(Configuration.AllNamespaces, cfg.SelectedNamespace); } + + [Fact] + public void ScreenSaver_defaults_and_clamps_idle_minutes() { + var settings = new ScreenSaverSettings(); + Assert.False(settings.Enabled); + Assert.Equal(ScreenSaverSettings.DefaultIdleMinutes, settings.IdleMinutes); + + settings.IdleMinutes = 0; + settings.Ensure(); + Assert.Equal(ScreenSaverSettings.DefaultIdleMinutes, settings.IdleMinutes); + + settings.IdleMinutes = 200; + settings.Ensure(); + Assert.Equal(ScreenSaverSettings.DefaultIdleMinutes, settings.IdleMinutes); + + settings.IdleMinutes = 15; + settings.Ensure(); + Assert.Equal(15, settings.IdleMinutes); + } + + [Fact] + public void Save_round_trips_screen_saver_settings() { + var path = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, """ + { + "Logging": { "LogLevel": { "Default": "Information" } }, + "Configuration": { "SelectedNamespace": "all" } + } + """); + + try { + var service = new ConfigurationFileService(path); + var cfg = service.Current; + cfg.ScreenSaver.Enabled = true; + cfg.ScreenSaver.IdleMinutes = 10; + service.Save(cfg); + + var reloaded = new ConfigurationFileService(path); + Assert.True(reloaded.Current.ScreenSaver.Enabled); + Assert.Equal(10, reloaded.Current.ScreenSaver.IdleMinutes); + Assert.Contains("\"ScreenSaver\"", File.ReadAllText(path), StringComparison.Ordinal); + } + finally { + File.Delete(path); + } + } } diff --git a/src/MaksIT.ClusterConsole.UI/Controls/BubblesScreenSaverControl.cs b/src/MaksIT.ClusterConsole.UI/Controls/BubblesScreenSaverControl.cs new file mode 100644 index 0000000..167ef8f --- /dev/null +++ b/src/MaksIT.ClusterConsole.UI/Controls/BubblesScreenSaverControl.cs @@ -0,0 +1,413 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; + + +namespace MaksIT.ClusterConsole.UI.Controls; + +public sealed class BubblesScreenSaverControl : Control { + private const double RefWidth = 1920; + private const double RefHeight = 1080; + private const double RefArea = RefWidth * RefHeight; + private const double RefBubbleCount = 30; + private const double RefMinRadius = 28; + private const double RefMaxRadius = 78; + private const double FrameSeconds = 1.0 / 60.0; + private const double EdgeOverhang = 0.55; + private const double SweepDeadlineSeconds = 55; + private const double SpeedSpread = 3.2; + private const double MaxSteerRadians = 0.028; + + private static readonly Color[] ThemePalette = [ + Color.Parse("#33A5CF"), + Color.Parse("#006199"), + Color.Parse("#8ab4f8"), + Color.Parse("#c5d9fb"), + Color.Parse("#00a7a0"), + Color.Parse("#5ec8e8"), + Color.Parse("#4a90c8"), + Color.Parse("#E8F4FC"), + Color.Parse("#7eb8d4"), + Color.Parse("#3d7ea8") + ]; + + private readonly List _bubbles = []; + private readonly DispatcherTimer _timer; + private readonly Random _random = new(); + private readonly List _coldCellOrder = []; + private double[] _lastVisit = []; + private int _coverCols; + private int _coverRows; + private Size _lastSize; + private double _scale = 1; + private double _time; + private bool _running; + + public BubblesScreenSaverControl() { + IsHitTestVisible = false; + ClipToBounds = true; + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _timer.Tick += (_, _) => { + Step(); + InvalidateVisual(); + }; + } + + public void Start() { + if (_running) + return; + + RebuildBubbles(force: true); + _time = 0; + _running = true; + IsVisible = true; + IsHitTestVisible = true; + _timer.Start(); + } + + public void Stop() { + if (!_running) + return; + + _running = false; + _timer.Stop(); + IsHitTestVisible = false; + IsVisible = false; + _bubbles.Clear(); + _lastVisit = []; + } + + public override void Render(DrawingContext context) { + if (Bounds.Width <= 0 || Bounds.Height <= 0) + return; + + foreach (var bubble in _bubbles) + DrawBubble(context, bubble); + } + + protected override void OnSizeChanged(SizeChangedEventArgs e) { + base.OnSizeChanged(e); + _lastSize = e.NewSize; + + if (_running) + RebuildBubbles(force: false); + } + + private void RebuildBubbles(bool force) { + var size = CurrentSize(); + + if (size.Width <= 0 || size.Height <= 0) + return; + + var scale = Math.Clamp(Math.Sqrt((size.Width * size.Height) / RefArea), 0.55, 2.4); + + if (!force && _bubbles.Count > 0 && Math.Abs(scale - _scale) < 0.08) { + EnsureCoverageGrid(size); + + return; + } + + _scale = scale; + _bubbles.Clear(); + EnsureCoverageGrid(size); + + var count = (int)Math.Clamp(Math.Round(RefBubbleCount * (size.Width * size.Height) / RefArea), 16, 56); + PlaceBubbles(size, count); + _bubbles.Sort((a, b) => a.Z.CompareTo(b.Z)); + + foreach (var bubble in _bubbles) + MarkCoverage(size, bubble); + } + + private void EnsureCoverageGrid(Size size) { + var cell = Math.Max(28, RefMaxRadius * _scale * 0.5); + var cols = Math.Max(8, (int)Math.Ceiling(size.Width / cell)); + var rows = Math.Max(6, (int)Math.Ceiling(size.Height / cell)); + + if (_lastVisit.Length == cols * rows && _coverCols == cols && _coverRows == rows) + return; + + _coverCols = cols; + _coverRows = rows; + _lastVisit = new double[cols * rows]; + + for (var i = 0; i < _lastVisit.Length; i++) + _lastVisit[i] = -SweepDeadlineSeconds; + } + + private void PlaceBubbles(Size size, int count) { + var minR = RefMinRadius * _scale; + var maxR = RefMaxRadius * _scale; + var cols = Math.Max(1, (int)Math.Ceiling(Math.Sqrt(count * size.Width / Math.Max(1, size.Height)))); + var rows = Math.Max(1, (int)Math.Ceiling(count / (double)cols)); + + while (rows * cols < count) { + if (size.Width >= size.Height) + cols++; + else + rows++; + } + + var meanR = (minR + maxR) * 0.5; + var margin = meanR * EdgeOverhang; + var spanX = size.Width + margin * 2; + var spanY = size.Height + margin * 2; + var cellW = spanX / cols; + var cellH = spanY / rows; + var index = 0; + + for (var row = 0; row < rows && index < count; row++) { + for (var col = 0; col < cols && index < count; col++) { + var radius = minR + _random.NextDouble() * (maxR - minR); + var x = -margin + (col + 0.5) * cellW + (_random.NextDouble() - 0.5) * cellW * 0.5; + var y = -margin + (row + 0.5) * cellH + (_random.NextDouble() - 0.5) * cellH * 0.5; + var dx = (_random.NextDouble() - 0.5) * SpeedSpread * _scale; + var dy = (_random.NextDouble() - 0.5) * SpeedSpread * _scale; + + if (Math.Abs(dx) < 0.35 * _scale) + dx = 0.35 * _scale * (_random.Next(2) == 0 ? 1 : -1); + + if (Math.Abs(dy) < 0.35 * _scale) + dy = 0.35 * _scale * (_random.Next(2) == 0 ? 1 : -1); + + var bubble = new Bubble { + X = x, + Y = y, + Radius = radius, + Vx = dx, + Vy = dy, + Color = RandomBubbleColor(), + Z = _random.NextDouble() + }; + ClampToBounds(bubble, size); + _bubbles.Add(bubble); + index++; + } + } + } + + private Color RandomBubbleColor() => + ThemePalette[_random.Next(ThemePalette.Length)]; + + private static (double MinX, double MaxX, double MinY, double MaxY) BoundsFor(Size size, double radius) { + var margin = radius * EdgeOverhang; + + return (-margin, size.Width + margin, -margin, size.Height + margin); + } + + private void Step() { + var size = CurrentSize(); + + if (size.Width <= 0 || size.Height <= 0) + return; + + EnsureCoverageGrid(size); + _time += FrameSeconds; + NudgeHeadingsTowardStaleCells(size); + + foreach (var bubble in _bubbles) { + bubble.X += bubble.Vx; + bubble.Y += bubble.Vy; + BounceWalls(bubble, size); + MarkCoverage(size, bubble); + } + } + + private void NudgeHeadingsTowardStaleCells(Size size) { + if (_bubbles.Count == 0 || _lastVisit.Length == 0) + return; + + _coldCellOrder.Clear(); + + for (var i = 0; i < _lastVisit.Length; i++) + _coldCellOrder.Add(i); + + _coldCellOrder.Sort((a, b) => _lastVisit[a].CompareTo(_lastVisit[b])); + + var targets = Math.Min(_bubbles.Count / 3, Math.Max(3, _coverCols / 2)); + var assigned = new bool[_bubbles.Count]; + var cellW = size.Width / _coverCols; + var cellH = size.Height / _coverRows; + + for (var t = 0; t < targets && t < _coldCellOrder.Count; t++) { + var cell = _coldCellOrder[t]; + var age = _time - _lastVisit[cell]; + + if (age < SweepDeadlineSeconds * 0.45 && _time > 3) + break; + + var col = cell % _coverCols; + var row = cell / _coverCols; + var tx = (col + 0.5) * cellW; + var ty = (row + 0.5) * cellH; + var best = -1; + var bestDist = double.MaxValue; + + for (var i = 0; i < _bubbles.Count; i++) { + if (assigned[i]) + continue; + + var b = _bubbles[i]; + var dx = tx - b.X; + var dy = ty - b.Y; + var dist = dx * dx + dy * dy; + + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + + if (best < 0) + break; + + assigned[best] = true; + var bubble = _bubbles[best]; + var speed = Math.Sqrt(bubble.Vx * bubble.Vx + bubble.Vy * bubble.Vy); + + if (speed < 0.001) + continue; + + var current = Math.Atan2(bubble.Vy, bubble.Vx); + var target = Math.Atan2(ty - bubble.Y, tx - bubble.X); + var delta = NormalizeAngle(target - current); + var urgency = Math.Clamp(age / SweepDeadlineSeconds, 0.35, 1.25); + var turn = Math.Clamp(delta, -MaxSteerRadians * urgency, MaxSteerRadians * urgency); + var heading = current + turn; + bubble.Vx = Math.Cos(heading) * speed; + bubble.Vy = Math.Sin(heading) * speed; + } + } + + private static double NormalizeAngle(double radians) { + while (radians > Math.PI) + radians -= Math.PI * 2; + + while (radians < -Math.PI) + radians += Math.PI * 2; + + return radians; + } + + private void MarkCoverage(Size size, Bubble bubble) { + if (_lastVisit.Length == 0 || size.Width <= 0 || size.Height <= 0) + return; + + var cellW = size.Width / _coverCols; + var cellH = size.Height / _coverRows; + var r = bubble.Radius * 0.95; + var minCol = Math.Max(0, (int)Math.Floor((bubble.X - r) / cellW)); + var maxCol = Math.Min(_coverCols - 1, (int)Math.Floor((bubble.X + r) / cellW)); + var minRow = Math.Max(0, (int)Math.Floor((bubble.Y - r) / cellH)); + var maxRow = Math.Min(_coverRows - 1, (int)Math.Floor((bubble.Y + r) / cellH)); + var rSq = r * r; + + for (var row = minRow; row <= maxRow; row++) { + for (var col = minCol; col <= maxCol; col++) { + var cx = (col + 0.5) * cellW; + var cy = (row + 0.5) * cellH; + var dx = cx - bubble.X; + var dy = cy - bubble.Y; + + if (dx * dx + dy * dy <= rSq) + _lastVisit[row * _coverCols + col] = _time; + } + } + } + + private void BounceWalls(Bubble bubble, Size size) { + var (minX, maxX, minY, maxY) = BoundsFor(size, bubble.Radius); + + if (bubble.X < minX || bubble.X > maxX) { + bubble.Vx = -bubble.Vx; + bubble.X = Math.Clamp(bubble.X, minX, maxX); + } + + if (bubble.Y < minY || bubble.Y > maxY) { + bubble.Vy = -bubble.Vy; + bubble.Y = Math.Clamp(bubble.Y, minY, maxY); + } + } + + private static void ClampToBounds(Bubble bubble, Size size) { + var (minX, maxX, minY, maxY) = BoundsFor(size, bubble.Radius); + bubble.X = Math.Clamp(bubble.X, minX, Math.Max(minX, maxX)); + bubble.Y = Math.Clamp(bubble.Y, minY, Math.Max(minY, maxY)); + } + + private static void DrawBubble(DrawingContext context, Bubble bubble) { + var x = bubble.X; + var y = bubble.Y; + var r = bubble.Radius; + var color = bubble.Color; + var clear = Color.FromArgb(0, color.R, color.G, color.B); + var fill = CreateBubbleGradient(x, y, r, color, clear); + + var body = new EllipseGeometry(new Rect(x - r, y - r, r * 2, r * 2)); + context.DrawGeometry(fill, null, body); + DrawRotatedEllipse( + context, + x - r / 2 - r * 0.05, + y - r / 2 - r * 0.15, + r / 20, + r / 6, + Math.PI / 3.5, + Brushes.White); + } + + private static RadialGradientBrush CreateBubbleGradient( + double x, + double y, + double radius, + Color color, + Color clear) => + new() { + Center = new RelativePoint(x, y, RelativeUnit.Absolute), + GradientOrigin = new RelativePoint(x, y, RelativeUnit.Absolute), + RadiusX = new RelativeScalar(radius, RelativeUnit.Absolute), + RadiusY = new RelativeScalar(radius, RelativeUnit.Absolute), + GradientStops = [ + new GradientStop(clear, 0), + new GradientStop(clear, 0.7), + new GradientStop(color, 1) + ] + }; + + private static void DrawRotatedEllipse( + DrawingContext context, + double cx, + double cy, + double rx, + double ry, + double radians, + IBrush brush) { + var transform = Matrix.CreateTranslation(-cx, -cy) + * Matrix.CreateRotation(radians) + * Matrix.CreateTranslation(cx, cy); + + using (context.PushTransform(transform)) { + var geometry = new EllipseGeometry(new Rect(cx - rx, cy - ry, rx * 2, ry * 2)); + context.DrawGeometry(brush, null, geometry); + } + } + + private Size CurrentSize() { + var size = Bounds.Size; + + if (size.Width <= 0 || size.Height <= 0) + size = _lastSize; + + return size; + } + + private sealed class Bubble { + public double X; + public double Y; + public double Radius; + public double Vx; + public double Vy; + public Color Color; + public double Z; + } +} diff --git a/src/MaksIT.ClusterConsole.UI/Services/LayoutOriginators.cs b/src/MaksIT.ClusterConsole.UI/Services/LayoutOriginators.cs index 05f8ca4..959c3df 100644 --- a/src/MaksIT.ClusterConsole.UI/Services/LayoutOriginators.cs +++ b/src/MaksIT.ClusterConsole.UI/Services/LayoutOriginators.cs @@ -36,7 +36,9 @@ internal sealed class WindowLayoutOriginator : ILayoutOriginator { if (layout.WindowX is int x && layout.WindowY is int y && IsOnScreen(x, y)) _window.Position = new PixelPoint(x, y); - if (Enum.TryParse(layout.WindowState, true, out var state) && state != WindowState.Minimized) + if (Enum.TryParse(layout.WindowState, true, out var state) + && state != WindowState.Minimized + && state != WindowState.FullScreen) _window.WindowState = state; } @@ -48,9 +50,11 @@ internal sealed class WindowLayoutOriginator : ILayoutOriginator { layout.WindowY = _window.Position.Y; } - layout.WindowState = _window.WindowState == WindowState.Minimized - ? WindowState.Normal.ToString() - : _window.WindowState.ToString(); + layout.WindowState = _window.WindowState switch { + WindowState.Minimized => WindowState.Normal.ToString(), + WindowState.FullScreen => WindowState.Maximized.ToString(), + _ => _window.WindowState.ToString() + }; } private bool IsOnScreen(int x, int y) { diff --git a/src/MaksIT.ClusterConsole.UI/Services/ScreenSaverController.cs b/src/MaksIT.ClusterConsole.UI/Services/ScreenSaverController.cs new file mode 100644 index 0000000..06d205a --- /dev/null +++ b/src/MaksIT.ClusterConsole.UI/Services/ScreenSaverController.cs @@ -0,0 +1,167 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using MaksIT.ClusterConsole.Shared; +using MaksIT.ClusterConsole.UI.Controls; +using MaksIT.ClusterConsole.UI.ViewModels.Shell; + + +namespace MaksIT.ClusterConsole.UI.Services; + +internal sealed class ScreenSaverController : IDisposable { + private readonly Window _window; + private readonly MainViewModel _viewModel; + private readonly ConfigurationFileService _configuration; + private readonly BubblesScreenSaverControl _overlay; + private readonly DispatcherTimer _idleTimer; + private bool _attached; + private bool _active; + + public ScreenSaverController( + Window window, + MainViewModel viewModel, + ConfigurationFileService configuration, + BubblesScreenSaverControl overlay) { + _window = window; + _viewModel = viewModel; + _configuration = configuration; + _overlay = overlay; + _idleTimer = new DispatcherTimer(); + _idleTimer.Tick += (_, _) => Activate(); + } + + public bool IsActive => _active; + + public void Attach() { + if (_attached) + return; + + _attached = true; + _window.PropertyChanged += OnWindowPropertyChanged; + _window.AddHandler(InputElement.PointerMovedEvent, OnPointerActivity, RoutingStrategies.Tunnel); + _window.AddHandler(InputElement.PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); + _window.AddHandler(InputElement.PointerWheelChangedEvent, OnPointerWheel, RoutingStrategies.Tunnel); + _window.AddHandler(InputElement.KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); + _viewModel.ScreenSaverSettingsChanged += OnSettingsChanged; + SyncFromWindowState(); + } + + public void Dispose() { + if (!_attached) + return; + + _attached = false; + _idleTimer.Stop(); + _overlay.Stop(); + _window.PropertyChanged -= OnWindowPropertyChanged; + _window.RemoveHandler(InputElement.PointerMovedEvent, OnPointerActivity); + _window.RemoveHandler(InputElement.PointerPressedEvent, OnPointerPressed); + _window.RemoveHandler(InputElement.PointerWheelChangedEvent, OnPointerWheel); + _window.RemoveHandler(InputElement.KeyDownEvent, OnKeyDown); + _viewModel.ScreenSaverSettingsChanged -= OnSettingsChanged; + } + + public bool TryDismiss() { + if (!_active) + return false; + + Dismiss(); + + return true; + } + + private void OnSettingsChanged() => + SyncFromWindowState(); + + private void OnWindowPropertyChanged(object? sender, Avalonia.AvaloniaPropertyChangedEventArgs e) { + if (e.Property == Window.WindowStateProperty) + SyncFromWindowState(); + } + + private void SyncFromWindowState() { + if (_window.WindowState != WindowState.FullScreen) { + StopIdle(); + Dismiss(); + + return; + } + + RestartIdleIfNeeded(); + } + + private void OnPointerActivity(object? sender, PointerEventArgs e) => + HandleActivity(e); + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) => + HandleActivity(e); + + private void OnPointerWheel(object? sender, PointerWheelEventArgs e) => + HandleActivity(e); + + private void HandleActivity(RoutedEventArgs e) { + if (_window.WindowState != WindowState.FullScreen) + return; + + if (_active) { + Dismiss(); + e.Handled = true; + + return; + } + + RestartIdleIfNeeded(); + } + + private void OnKeyDown(object? sender, KeyEventArgs e) { + if (_window.WindowState != WindowState.FullScreen) + return; + + if (_active) { + Dismiss(); + e.Handled = true; + + return; + } + + if (e.Key == Key.Escape) + return; + + RestartIdleIfNeeded(); + } + + private void RestartIdleIfNeeded() { + _idleTimer.Stop(); + + var settings = _configuration.Current.ScreenSaver; + settings.Ensure(); + + if (!settings.Enabled || _window.WindowState != WindowState.FullScreen) + return; + + _idleTimer.Interval = TimeSpan.FromMinutes(settings.IdleMinutes); + _idleTimer.Start(); + } + + private void StopIdle() => + _idleTimer.Stop(); + + private void Activate() { + _idleTimer.Stop(); + + if (!_configuration.Current.ScreenSaver.Enabled || _window.WindowState != WindowState.FullScreen) + return; + + _active = true; + _overlay.Start(); + } + + private void Dismiss() { + if (!_active && !_overlay.IsVisible) + return; + + _active = false; + _overlay.Stop(); + RestartIdleIfNeeded(); + } +} diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/ScreenSaverSettingsViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/ScreenSaverSettingsViewModel.cs new file mode 100644 index 0000000..a628067 --- /dev/null +++ b/src/MaksIT.ClusterConsole.UI/ViewModels/ScreenSaverSettingsViewModel.cs @@ -0,0 +1,55 @@ +using Avalonia; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using MaksIT.ClusterConsole.Shared; +using MaksIT.ClusterConsole.UI.Controls.Footer; + + +namespace MaksIT.ClusterConsole.UI.ViewModels; + +public partial class ScreenSaverSettingsViewModel : ObservableObject { + private readonly Action _apply; + + public ScreenSaverSettingsViewModel(bool enabled, int idleMinutes, Action apply) { + _apply = apply; + this.enabled = enabled; + this.idleMinutes = idleMinutes; + FooterItems = [ + new FooterButton("Cancel", CancelCommand) { + Edge = FooterEdge.Trailing, + IsCancel = true, + Padding = new Thickness(12, 7) + }, + new FooterButton("Save", SaveCommand) { + Edge = FooterEdge.Trailing, + IsDefault = true, + Padding = new Thickness(12, 7) + } + ]; + } + + [ObservableProperty] + private bool enabled; + + [ObservableProperty] + private int idleMinutes; + + public event Action? CloseRequested; + + public IReadOnlyList FooterItems { get; } + + [RelayCommand] + private void Save() { + var minutes = IdleMinutes; + + if (minutes < ScreenSaverSettings.MinIdleMinutes || minutes > ScreenSaverSettings.MaxIdleMinutes) + minutes = ScreenSaverSettings.DefaultIdleMinutes; + + _apply(Enabled, minutes); + CloseRequested?.Invoke(true); + } + + [RelayCommand] + private void Cancel() => + CloseRequested?.Invoke(false); +} diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/Shell/MainViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/Shell/MainViewModel.cs index 528b4dd..ecb94be 100644 --- a/src/MaksIT.ClusterConsole.UI/ViewModels/Shell/MainViewModel.cs +++ b/src/MaksIT.ClusterConsole.UI/ViewModels/Shell/MainViewModel.cs @@ -389,6 +389,23 @@ public partial class MainViewModel : ObservableObject, IDisposable { public event EventHandler? AiSettingsRequested; + [RelayCommand] + private void OpenScreenSaverSettings() => + ScreenSaverSettingsRequested?.Invoke(this, EventArgs.Empty); + + public event EventHandler? ScreenSaverSettingsRequested; + + public event Action? ScreenSaverSettingsChanged; + + [ObservableProperty] + private bool isFullScreen; + + [RelayCommand] + private void ToggleFullScreen() => + FullScreenToggleRequested?.Invoke(this, EventArgs.Empty); + + public event EventHandler? FullScreenToggleRequested; + public event Action? VolumeFilesRequested; public Func? ShowRetainReclaim { get; set; } @@ -431,6 +448,24 @@ public partial class MainViewModel : ObservableObject, IDisposable { page.Chat.NotifySettingsChanged(); } + public ScreenSaverSettingsViewModel CreateScreenSaverSettingsViewModel() { + var cfg = _configuration.Current; + + return new ScreenSaverSettingsViewModel( + cfg.ScreenSaver.Enabled, + cfg.ScreenSaver.IdleMinutes, + ApplyScreenSaverSettings); + } + + private void ApplyScreenSaverSettings(bool enabled, int idleMinutes) { + var cfg = _configuration.Current; + cfg.ScreenSaver.Enabled = enabled; + cfg.ScreenSaver.IdleMinutes = idleMinutes; + cfg.EnsureDefaults(); + _configuration.Save(cfg); + ScreenSaverSettingsChanged?.Invoke(); + } + public Task ConnectNamedAsync(string name) { var item = Catalog.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.Ordinal)); diff --git a/src/MaksIT.ClusterConsole.UI/Windows/MainWindow.axaml b/src/MaksIT.ClusterConsole.UI/Windows/MainWindow.axaml index b9150a3..d06a84a 100644 --- a/src/MaksIT.ClusterConsole.UI/Windows/MainWindow.axaml +++ b/src/MaksIT.ClusterConsole.UI/Windows/MainWindow.axaml @@ -1,5 +1,6 @@ + + + + + + + + @@ -228,4 +240,8 @@ + + diff --git a/src/MaksIT.ClusterConsole.UI/Windows/MainWindow.axaml.cs b/src/MaksIT.ClusterConsole.UI/Windows/MainWindow.axaml.cs index 81c359a..6dd593c 100644 --- a/src/MaksIT.ClusterConsole.UI/Windows/MainWindow.axaml.cs +++ b/src/MaksIT.ClusterConsole.UI/Windows/MainWindow.axaml.cs @@ -1,8 +1,10 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Controls.ApplicationLifetimes; using MaksIT.ClusterConsole.Shared; +using MaksIT.ClusterConsole.UI.Controls; using MaksIT.ClusterConsole.UI.Services; using MaksIT.ClusterConsole.UI.ViewModels.Shell; using MaksIT.ClusterConsole.UI.ViewModels.Cluster; @@ -13,6 +15,9 @@ namespace MaksIT.ClusterConsole.UI.Windows; public partial class MainWindow : Window { private LayoutPersistence? _layout; + private ScreenSaverController? _screenSaver; + private WindowState _windowedState = WindowState.Normal; + private MainViewModel? _viewModel; public MainWindow() { InitializeComponent(); @@ -20,14 +25,20 @@ public partial class MainWindow : Window { public MainWindow(MainViewModel viewModel, ConfigurationFileService configuration, ScreenshotTourOptions? tour = null) : this() { DataContext = viewModel; + _viewModel = viewModel; _layout = new LayoutPersistence( this, configuration, () => viewModel.ActivePage?.Name, () => viewModel.SelectedDescriptor?.Id); + var overlay = this.FindControl("ScreenSaverOverlay") + ?? throw new InvalidOperationException("ScreenSaverOverlay is missing."); + _screenSaver = new ScreenSaverController(this, viewModel, configuration, overlay); + Opened += async (_, _) => { _layout.Attach(); + _screenSaver.Attach(); if (tour is not null) { var desktop = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; @@ -39,14 +50,72 @@ public partial class MainWindow : Window { await WhatsNewWindow.ShowIfNeededAsync(this, configuration); }; + Closed += (_, _) => _screenSaver?.Dispose(); + + PropertyChanged += OnWindowPropertyChanged; + viewModel.FullScreenToggleRequested += (_, _) => ToggleFullScreen(); viewModel.ConnectionsRequested += async (_, _) => await OpenConnectionsAsync(viewModel); viewModel.AiSettingsRequested += async (_, _) => await OpenAiSettingsAsync(viewModel); + viewModel.ScreenSaverSettingsRequested += async (_, _) => await OpenScreenSaverSettingsAsync(viewModel); viewModel.VolumeFilesRequested += OpenVolumeFiles; viewModel.ShowRetainReclaim = ShowRetainReclaimAsync; viewModel.ShowDrainPreview = ShowDrainPreviewAsync; viewModel.ConfirmLonghornAttach = dialog => LonghornAttachWindow.ShowAsync(this, dialog); viewModel.ConfirmLonghornDetach = dialog => LonghornDetachWindow.ShowAsync(this, dialog); viewModel.ConfirmLonghornBackupJob = dialog => LonghornBackupJobWindow.ShowAsync(this, dialog); + + AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel); + } + + private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { + if (e.Property != WindowStateProperty || _viewModel is null) + return; + + var state = WindowState; + + if (state is WindowState.Normal or WindowState.Maximized) + _windowedState = state; + + _viewModel.IsFullScreen = state == WindowState.FullScreen; + } + + private void ToggleFullScreen() { + if (WindowState == WindowState.FullScreen) + ExitFullScreen(); + else + EnterFullScreen(); + } + + private void EnterFullScreen() { + if (WindowState is WindowState.Normal or WindowState.Maximized) + _windowedState = WindowState; + + WindowState = WindowState.FullScreen; + } + + private void ExitFullScreen() { + if (WindowState != WindowState.FullScreen) + return; + + WindowState = _windowedState is WindowState.Maximized + ? WindowState.Maximized + : WindowState.Normal; + } + + private void OnPreviewKeyDown(object? sender, KeyEventArgs e) { + if (e.Key != Key.Escape) + return; + + if (_screenSaver?.TryDismiss() == true) { + e.Handled = true; + + return; + } + + if (WindowState == WindowState.FullScreen) { + ExitFullScreen(); + e.Handled = true; + } } private void OnLogsClick(object? sender, RoutedEventArgs e) => @@ -73,6 +142,11 @@ public partial class MainWindow : Window { await window.ShowDialog(this); } + private async Task OpenScreenSaverSettingsAsync(MainViewModel viewModel) { + var window = new ScreenSaverSettingsWindow(viewModel.CreateScreenSaverSettingsViewModel()); + await window.ShowDialog(this); + } + private async Task OpenConnectionsAsync(MainViewModel viewModel) { var window = new ConnectionsWindow(viewModel.CreateConnectionsViewModel()); var connect = await window.ShowDialog(this); diff --git a/src/MaksIT.ClusterConsole.UI/Windows/ScreenSaverSettingsWindow.axaml b/src/MaksIT.ClusterConsole.UI/Windows/ScreenSaverSettingsWindow.axaml new file mode 100644 index 0000000..f21b6ef --- /dev/null +++ b/src/MaksIT.ClusterConsole.UI/Windows/ScreenSaverSettingsWindow.axaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + diff --git a/src/MaksIT.ClusterConsole.UI/Windows/ScreenSaverSettingsWindow.axaml.cs b/src/MaksIT.ClusterConsole.UI/Windows/ScreenSaverSettingsWindow.axaml.cs new file mode 100644 index 0000000..a92d65c --- /dev/null +++ b/src/MaksIT.ClusterConsole.UI/Windows/ScreenSaverSettingsWindow.axaml.cs @@ -0,0 +1,16 @@ +using Avalonia.Controls; +using MaksIT.ClusterConsole.UI.ViewModels; + + +namespace MaksIT.ClusterConsole.UI.Windows; + +public partial class ScreenSaverSettingsWindow : Window { + public ScreenSaverSettingsWindow() { + InitializeComponent(); + } + + public ScreenSaverSettingsWindow(ScreenSaverSettingsViewModel viewModel) : this() { + DataContext = viewModel; + viewModel.CloseRequested += saved => Close(saved); + } +}