Compare commits

...
Author SHA1 Message Date
chrislu 8493871406 remove sqlite due to GOOS support for freebsd and openbsd 2025-07-06 21:50:08 -07:00
chrislu a7c57caa3f 3.93 2025-07-06 21:06:50 -07:00
fd4154cfed fix: s3 return BadDigest (#6714)
* fix: s3 return BadDigest

* adjust error message checking

---------

Co-authored-by: chrislu <chris.lu@gmail.com>
2025-07-06 14:18:57 -07:00
chrislu 798f797158 use float for sleep seconds
fix https://github.com/seaweedfs/seaweedfs/pull/6795
2025-07-06 14:16:41 -07:00
Chris LuandGitHub 9b7f3b78b7 enhance remote.cache to sync meta only, delete local extra (#6941) 2025-07-06 13:58:25 -07:00
Chris LuandGitHub aa66852304 Admin UI add maintenance menu (#6944)
* add ui for maintenance

* valid config loading. fix workers page.

* refactor

* grpc between admin and workers

* add a long-running bidirectional grpc call between admin and worker
* use the grpc call to heartbeat
* use the grpc call to communicate
* worker can remove the http client
* admin uses http port + 10000 as its default grpc port

* one task one package

* handles connection failures gracefully with exponential backoff

* grpc with insecure tls

* grpc with optional tls

* fix detecting tls

* change time config from nano seconds to seconds

* add tasks with 3 interfaces

* compiles reducing hard coded

* remove a couple of tasks

* remove hard coded references

* reduce hard coded values

* remove hard coded values

* remove hard coded from templ

* refactor maintenance package

* fix import cycle

* simplify

* simplify

* auto register

* auto register factory

* auto register task types

* self register types

* refactor

* simplify

* remove one task

* register ui

* lazy init executor factories

* use registered task types

* DefaultWorkerConfig remove hard coded task types

* remove more hard coded

* implement get maintenance task

* dynamic task configuration

* "System Settings" should only have system level settings

* adjust menu for tasks

* ensure menu not collapsed

* render job configuration well

* use templ for ui of task configuration

* fix ordering

* fix bugs

* saving duration in seconds

* use value and unit for duration

* Delete WORKER_REFACTORING_PLAN.md

* Delete maintenance.json

* Delete custom_worker_example.go

* remove address from workers

* remove old code from ec task

* remove creating collection button

* reconnect with exponential backoff

* worker use security.toml

* start admin server with tls info from security.toml

* fix "weed admin" cli description
2025-07-06 13:57:02 -07:00
chrislu 302e62d480 link to volume details 2025-07-04 14:45:13 -07:00
chrislu df30737505 adjust enterprise link 2025-07-04 14:44:38 -07:00
chrislu fc55d2d8e0 link to volume detail page 2025-07-04 13:55:43 -07:00
chrislu d8da465cd3 fix link to volume server; display volume space usage 2025-07-04 13:52:28 -07:00
chrislu e85fbd29a1 refactor 2025-07-04 13:33:58 -07:00
chrislu a5f48de7d6 add vacuum operation 2025-07-04 13:11:43 -07:00
chrislu 0c1d4b2d08 tweak ui 2025-07-04 12:56:28 -07:00
chrislu 84d4ea0995 show volume details 2025-07-04 12:49:34 -07:00
chrislu 2cfe079a1f weed admin: remove system health status 2025-07-02 23:57:36 -07:00
chrislu e89941f390 weed admin: minor 2025-07-02 23:46:26 -07:00
chrislu 645b6d2603 weed admin: disk types card can also show all disk type values, not just a count. 2025-07-02 23:31:17 -07:00
chrislu 5e6d94319b weed admin: add version to volumes page 2025-07-02 23:28:00 -07:00
chrislu ebb16f474c remove status fields 2025-07-02 23:17:27 -07:00
chrislu 40e3eae21f admin ui: filter by collection 2025-07-02 22:48:21 -07:00
chrislu 2268d2f55e add back dynamic columns 2025-07-02 22:06:06 -07:00
chrislu 1cac3e73f9 show counts for rack and disk type 2025-07-02 21:53:06 -07:00
Chris LuandGitHub 080dce80eb weed admin UI dynamically show columns (#6939)
* show counts for rack and disk type

* dynamically display columns if more than one value

* adjust ui
2025-07-02 21:49:37 -07:00
chrislu ac82dd7c07 Delete admin 2025-07-02 19:35:02 -07:00
93007c1842 [volume] refactor and add metrics for flight upload and download data limit condition (#6920)
* refactor concurrentDownloadLimit

* fix loop

* fix cmdServer

* fix: resolve conversation pr 6920

* Changes logging function (#6919)

* updated logging methods for stores

* updated logging methods for stores

* updated logging methods for filer

* updated logging methods for uploader and http_util

* updated logging methods for weed server

---------

Co-authored-by: akosov <a.kosov@kryptonite.ru>

* Improve lock ring (#6921)

* fix flaky lock ring test

* add more tests

* fix: build

* fix: rm import util/version

* fix: serverOptions

* refactoring

---------

Co-authored-by: Aleksey Kosov <rusyak777@list.ru>
Co-authored-by: akosov <a.kosov@kryptonite.ru>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: chrislu <chris.lu@gmail.com>
2025-07-02 18:03:49 -07:00
Chris LuandGitHub 1db7c2b8aa Add credential storage (#6938)
* add credential store interface

* load credential.toml

* lint

* create credentialManager with explicit store type

* add type name

* InitializeCredentialManager

* remove unused functions

* fix missing import

* fix import

* fix nil configuration
2025-07-02 18:03:17 -07:00
chrislu 6b706f9ccd rename files
*_server.go - main server files
*_management.go - business logic
*_data.go - data structures and types
*_middleware.go - middleware logic
2025-07-02 00:04:46 -07:00
chrislu f47c4aef5a object store users 2025-07-02 00:00:23 -07:00
chrislu 4aec3c3fb9 purge unused 2025-07-01 23:16:48 -07:00
chrislu 76d773bf88 viewer, download, properties 2025-07-01 21:27:38 -07:00
chrislu d4f4c04361 remove ttl for collections 2025-07-01 21:11:31 -07:00
chrislu 2f9321086f add version, add alpha, add link to enterprise version 2025-07-01 21:02:12 -07:00
chrislu 471910736d remove ttl from collections 2025-07-01 21:01:49 -07:00
chrislu 757c436a82 collection has multiple disk types 2025-07-01 20:27:42 -07:00
chrislu b2849ec435 set uid gid 2025-07-01 20:03:50 -07:00
chrislu ae1d0a82ce add bucket quota 2025-07-01 19:59:45 -07:00
chrislu 5c2b2e5513 clean up s3 bucket references 2025-07-01 08:41:53 -07:00
1defee3d68 Add admin component (#6928)
* init version

* relocate

* add s3 bucket link

* refactor handlers into weed/admin folder

* fix login logout

* adding favicon

* remove fall back to http get topology

* grpc dial option, disk total capacity

* show filer count

* fix each volume disk usage

* add filers to dashboard

* adding hosts, volumes, collections

* refactor code and menu

* remove "refresh" button

* fix data for collections

* rename cluster hosts into volume servers

* add masters, filers

* reorder

* adding file browser

* create folder and upload files

* add filer version, created at time

* remove mock data

* remove fields

* fix submenu item highlighting

* fix bucket creation

* purge files

* delete multiple

* fix bucket creation

* remove region from buckets

* add object store with buckets and users

* rendering permission

* refactor

* get bucket objects and size

* link to file browser

* add file size and count for collections page

* paginate the volumes

* fix possible SSRF

https://github.com/seaweedfs/seaweedfs/pull/6928/checks?check_run_id=45108469801

* Update weed/command/admin.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update weed/command/admin.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix build

* import

* remove filer CLI option

* remove filer option

* remove CLI options

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-01 01:28:09 -07:00
chrislu e5adc3872a ensure deleted entries are deleted
fix https://github.com/seaweedfs/seaweedfs/issues/6936
2025-07-01 00:45:13 -07:00
chrislu 7ab3b19e37 remove unused import 2025-07-01 00:24:42 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
190bc7f75f chore(deps): bump google.golang.org/api from 0.238.0 to 0.239.0 (#6935)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.238.0 to 0.239.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.238.0...v0.239.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.239.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 16:55:42 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
06326ea148 chore(deps): bump gocloud.dev/pubsub/natspubsub from 0.41.0 to 0.42.0 (#6933)
Bumps [gocloud.dev/pubsub/natspubsub](https://github.com/google/go-cloud) from 0.41.0 to 0.42.0.
- [Release notes](https://github.com/google/go-cloud/releases)
- [Commits](https://github.com/google/go-cloud/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: gocloud.dev/pubsub/natspubsub
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 14:57:23 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1e4dab9c5c chore(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.81.0 to 1.82.0 (#6934)
chore(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.81.0 to 1.82.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.81.0...service/s3/v1.82.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.82.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 14:57:17 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ec4ef30483 chore(deps): bump actions/setup-go from 4 to 5 (#6931)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 5.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 14:56:54 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
73112191e2 chore(deps): bump github.com/rclone/rclone from 1.70.1 to 1.70.2 (#6930)
Bumps [github.com/rclone/rclone](https://github.com/rclone/rclone) from 1.70.1 to 1.70.2.
- [Release notes](https://github.com/rclone/rclone/releases)
- [Changelog](https://github.com/rclone/rclone/blob/master/RELEASE.md)
- [Commits](https://github.com/rclone/rclone/compare/v1.70.1...v1.70.2)

---
updated-dependencies:
- dependency-name: github.com/rclone/rclone
  dependency-version: 1.70.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 14:56:45 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f2033e39bc chore(deps): bump github.com/ydb-platform/ydb-go-sdk/v3 from 3.111.0 to 3.111.3 (#6929)
chore(deps): bump github.com/ydb-platform/ydb-go-sdk/v3

Bumps [github.com/ydb-platform/ydb-go-sdk/v3](https://github.com/ydb-platform/ydb-go-sdk) from 3.111.0 to 3.111.3.
- [Release notes](https://github.com/ydb-platform/ydb-go-sdk/releases)
- [Changelog](https://github.com/ydb-platform/ydb-go-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ydb-platform/ydb-go-sdk/compare/v3.111.0...v3.111.3)

---
updated-dependencies:
- dependency-name: github.com/ydb-platform/ydb-go-sdk/v3
  dependency-version: 3.111.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 14:56:32 -07:00
chrislu 060ee1b9d5 fix tests 2025-06-30 13:57:28 -07:00
chrislu 2d0d429d2f fix disk space calculation 2025-06-30 10:11:30 -07:00
chrislu 3023a6f3a4 update doc 2025-06-28 20:27:26 -07:00
chrislu a788d9ab53 remove unnecessary code 2025-06-28 20:26:46 -07:00
chrislu 3d519fa2a6 only leader master should send telemetry 2025-06-28 20:06:48 -07:00
chrislu 1733d0ce68 remove features and deployments fields 2025-06-28 20:03:06 -07:00
chrislu 166e36bcd3 use telemetry.seaweedfs.com 2025-06-28 19:48:03 -07:00
chrislu adc7807451 update deploying 2025-06-28 14:59:55 -07:00
chrislu 52097a1d9b fix binary location 2025-06-28 14:43:12 -07:00
chrislu 4cd6c3ec36 copy telemetry server 2025-06-28 14:21:04 -07:00
Chris LuandGitHub a1aab8a083 add telemetry (#6926)
* add telemetry

* fix go mod

* add default telemetry server url

* Update README.md

* replace with broker count instead of s3 count

* Update telemetry.pb.go

* github action to deploy
2025-06-28 14:11:55 -07:00
dependabot[bot]andGitHub 29892c43ff chore(deps): bump github.com/go-viper/mapstructure/v2 from 2.2.1 to 2.3.0 (#6925) 2025-06-27 10:37:38 -07:00
chrislu 5e79436498 reference seaweedfs.com 2025-06-26 11:09:17 -07:00
chaletandGitHub 877b9b788a update s3 session cache key (#6923) 2025-06-26 03:21:35 -07:00
chrislu ab49540d2b use master.toml value if not empty
fix https://github.com/seaweedfs/seaweedfs/issues/6922
2025-06-25 17:54:56 -07:00
Chris LuandGitHub 95261a712e Improve lock ring (#6921)
* fix flaky lock ring test

* add more tests
2025-06-24 23:04:39 -07:00
4511c2cc1f Changes logging function (#6919)
* updated logging methods for stores

* updated logging methods for stores

* updated logging methods for filer

* updated logging methods for uploader and http_util

* updated logging methods for weed server

---------

Co-authored-by: akosov <a.kosov@kryptonite.ru>
2025-06-24 08:44:06 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2cdd8092cc chore(deps): bump github.com/go-sql-driver/mysql from 1.9.2 to 1.9.3 (#6916)
Bumps [github.com/go-sql-driver/mysql](https://github.com/go-sql-driver/mysql) from 1.9.2 to 1.9.3.
- [Release notes](https://github.com/go-sql-driver/mysql/releases)
- [Changelog](https://github.com/go-sql-driver/mysql/blob/v1.9.3/CHANGELOG.md)
- [Commits](https://github.com/go-sql-driver/mysql/compare/v1.9.2...v1.9.3)

---
updated-dependencies:
- dependency-name: github.com/go-sql-driver/mysql
  dependency-version: 1.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 14:12:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e222883dd0 chore(deps): bump github.com/ydb-platform/ydb-go-sdk/v3 from 3.110.1 to 3.111.0 (#6918)
chore(deps): bump github.com/ydb-platform/ydb-go-sdk/v3

---
updated-dependencies:
- dependency-name: github.com/ydb-platform/ydb-go-sdk/v3
  dependency-version: 3.111.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 14:11:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3b6155f4ee chore(deps): bump cloud.google.com/go/storage from 1.54.0 to 1.55.0 (#6914)
Bumps [cloud.google.com/go/storage](https://github.com/googleapis/google-cloud-go) from 1.54.0 to 1.55.0.
- [Release notes](https://github.com/googleapis/google-cloud-go/releases)
- [Changelog](https://github.com/googleapis/google-cloud-go/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-cloud-go/compare/spanner/v1.54.0...spanner/v1.55.0)

---
updated-dependencies:
- dependency-name: cloud.google.com/go/storage
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 13:16:13 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
29fa698414 chore(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.80.1 to 1.81.0 (#6912)
chore(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.80.1 to 1.81.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.80.1...service/s3/v1.81.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.81.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 12:01:33 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f68f55c7e9 chore(deps): bump github.com/rclone/rclone from 1.69.3 to 1.70.1 (#6909)
Bumps [github.com/rclone/rclone](https://github.com/rclone/rclone) from 1.69.3 to 1.70.1.
- [Release notes](https://github.com/rclone/rclone/releases)
- [Changelog](https://github.com/rclone/rclone/blob/master/RELEASE.md)
- [Commits](https://github.com/rclone/rclone/compare/v1.69.3...v1.70.1)

---
updated-dependencies:
- dependency-name: github.com/rclone/rclone
  dependency-version: 1.70.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:53:57 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a5bb5e04da chore(deps): bump docker/setup-buildx-action from 3.11.0 to 3.11.1 (#6910)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.11.0 to 3.11.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/18ce135bb5112fa8ce4ed6c17ab05699d7f3a5e0...e468171a9de216ec08956ac3ada2f0791b6bd435)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:48:17 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7f1f826468 chore(deps): bump github.com/minio/crc64nvme from 1.0.1 to 1.0.2 (#6908)
Bumps [github.com/minio/crc64nvme](https://github.com/minio/crc64nvme) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/minio/crc64nvme/releases)
- [Commits](https://github.com/minio/crc64nvme/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: github.com/minio/crc64nvme
  dependency-version: 1.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:44:01 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
47b9db0215 chore(deps): bump golang.org/x/image from 0.27.0 to 0.28.0 (#6906)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.27.0 to 0.28.0.
- [Commits](https://github.com/golang/image/compare/v0.27.0...v0.28.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:26:20 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ba8d261591 chore(deps): bump google.golang.org/api from 0.234.0 to 0.238.0 (#6907)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.234.0 to 0.238.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.234.0...v0.238.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.238.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 10:26:12 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris Luchrislu
5f1d2a9745 chore(deps): bump github.com/parquet-go/parquet-go from 0.24.0 to 0.25.1 (#6851)
* chore(deps): bump github.com/parquet-go/parquet-go from 0.24.0 to 0.25.1

Bumps [github.com/parquet-go/parquet-go](https://github.com/parquet-go/parquet-go) from 0.24.0 to 0.25.1.
- [Release notes](https://github.com/parquet-go/parquet-go/releases)
- [Changelog](https://github.com/parquet-go/parquet-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/parquet-go/parquet-go/compare/v0.24.0...v0.25.1)

---
updated-dependencies:
- dependency-name: github.com/parquet-go/parquet-go
  dependency-version: 0.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* adjust to updated API

Fixed Reader Construction: Updated to use parquet.OpenFile() instead of passing io.Reader directly to NewReader()
Fixed EOF Handling: Changed the order of operations to process rows before checking for EOF
Added Zero Row Count Check: Added explicit check for rowCount == 0 as an additional termination condition

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: chrislu <chris.lu@gmail.com>
2025-06-23 10:25:51 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b27ba8e984 chore(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.29.14 to 1.29.17 (#6905)
chore(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.29.14 to 1.29.17.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.29.14...config/v1.29.17)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.29.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 09:40:44 -07:00
chrislu 7324cb7171 3.92 2025-06-22 21:17:06 -07:00
Yixing ChengandGitHub 5a7d226d93 chore: keep master statefulSet chart up-to-date (#6903)
This patch adds some missing master options to the helm chart of master statefulSet.
2025-06-20 17:30:17 -07:00
Chris LuandGitHub 2b3385e201 Helm Charts: add ip bind for filer (#6902)
add ip bind for filer

fix https://github.com/seaweedfs/seaweedfs/issues/6900
2025-06-20 10:46:57 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
828228dbb0 chore(deps): bump github.com/go-chi/chi/v5 from 5.1.0 to 5.2.2 (#6901)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.1.0 to 5.2.2.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.1.0...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-20 10:00:14 -07:00
Aleksey KosovandGitHub 90c128e7a6 Context-based logging with request ID (#6899) 2025-06-20 06:23:53 -07:00
Chris LuandGitHub a72c442945 Fix chunked data reading if iam not enabled (#6898)
* fix chunked data reading if iam not enabled

* add unit test
2025-06-19 22:58:10 -07:00
Chris LuandGitHub f52134f9a1 adding metricsIp in Helm chart (#6897) 2025-06-19 22:52:19 -07:00
SmoothDenisandGitHub abd5102819 ydb filer improvements (#6890)
* fix(ydb): table creation with params

* fix(ydb): migrate to new query client & prevent creation table-bucket on get request

* fix(ydb): use new query client with kv req

* fix(ydb): use directory in every query

* fix(ydb): del unused import

* fix(ydb): tests & default const usage
2025-06-19 14:16:01 -07:00
chrislu da728750be follow grow volume option version 2025-06-19 13:54:54 -07:00
chrislu 2f1b3d68d7 pass volume version when creating a volume 2025-06-19 01:15:25 -07:00
chrislu 87927d068b display volume version when listing 2025-06-19 00:33:01 -07:00
chrislu 9a115068af adding cassandra2
fix https://github.com/seaweedfs/seaweedfs/issues/6888
2025-06-17 11:25:37 -07:00
chrislu 748bf5e4d3 add default value when reading needle version 2025-06-16 23:35:03 -07:00
chrislu c602f53a6e tail-volume-uses-the-source-volume-version 2025-06-16 22:46:13 -07:00
chrislu d2be5822a1 refactoring 2025-06-16 22:25:22 -07:00
chrislu 96632a34b1 add version to volume proto 2025-06-16 22:05:06 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
11f37cd9f2 chore(deps): bump github.com/rdleal/intervalst from 1.4.1 to 1.5.0 (#6882)
Bumps [github.com/rdleal/intervalst](https://github.com/rdleal/intervalst) from 1.4.1 to 1.5.0.
- [Commits](https://github.com/rdleal/intervalst/compare/v1.4.1...v1.5.0)

---
updated-dependencies:
- dependency-name: github.com/rdleal/intervalst
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 14:23:12 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
34c6249886 chore(deps): bump github.com/hanwen/go-fuse/v2 from 2.7.3-0.20250605191109-50f6569d1a7d to 2.8.0 (#6880)
chore(deps): bump github.com/hanwen/go-fuse/v2

Bumps [github.com/hanwen/go-fuse/v2](https://github.com/hanwen/go-fuse) from 2.7.3-0.20250605191109-50f6569d1a7d to 2.8.0.
- [Commits](https://github.com/hanwen/go-fuse/commits/v2.8.0)

---
updated-dependencies:
- dependency-name: github.com/hanwen/go-fuse/v2
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 14:22:27 -07:00
Chris LuandGitHub 5d8a391b95 filer store: fix nil for mongodb (#6886)
fix https://github.com/seaweedfs/seaweedfs/issues/6885
2025-06-16 14:21:05 -07:00
dependabot[bot]andGitHub 06a3140142 chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials from 1.17.68 to 1.17.69 (#6883) 2025-06-16 12:51:21 -07:00
AlexeyandGitHub 29d1701c34 Fix url scheme using for forwarded request with changed proto (#6884) 2025-06-16 12:50:09 -07:00
chrislu 78069605a6 sort lifecycles 2025-06-16 11:48:29 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
549fb110d7 chore(deps): bump golang.org/x/tools from 0.33.0 to 0.34.0 (#6878)
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.33.0 to 0.34.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.33.0...v0.34.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-version: 0.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 11:03:20 -07:00
chrislu f0e987dc9d ensure consistent testing 2025-06-16 10:56:39 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f598d8e84c chore(deps): bump github.com/getsentry/sentry-go from 0.31.1 to 0.33.0 (#6879)
Bumps [github.com/getsentry/sentry-go](https://github.com/getsentry/sentry-go) from 0.31.1 to 0.33.0.
- [Release notes](https://github.com/getsentry/sentry-go/releases)
- [Changelog](https://github.com/getsentry/sentry-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-go/compare/v0.31.1...v0.33.0)

---
updated-dependencies:
- dependency-name: github.com/getsentry/sentry-go
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 10:47:24 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d6de561650 chore(deps): bump golang.org/x/net from 0.40.0 to 0.41.0 (#6877)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.40.0 to 0.41.0.
- [Commits](https://github.com/golang/net/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 10:45:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0cd3483158 chore(deps): bump google.golang.org/grpc from 1.72.1 to 1.73.0 (#6876)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.72.1 to 1.73.0.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.72.1...v1.73.0)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.73.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 10:23:41 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
db36e89e7b chore(deps): bump go.mongodb.org/mongo-driver from 1.17.3 to 1.17.4 (#6875)
Bumps [go.mongodb.org/mongo-driver](https://github.com/mongodb/mongo-go-driver) from 1.17.3 to 1.17.4.
- [Release notes](https://github.com/mongodb/mongo-go-driver/releases)
- [Commits](https://github.com/mongodb/mongo-go-driver/compare/v1.17.3...v1.17.4)

---
updated-dependencies:
- dependency-name: go.mongodb.org/mongo-driver
  dependency-version: 1.17.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 10:23:34 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d8e8e11519 chore(deps): bump github.com/ydb-platform/ydb-go-sdk/v3 from 3.108.3 to 3.110.1 (#6874)
chore(deps): bump github.com/ydb-platform/ydb-go-sdk/v3

Bumps [github.com/ydb-platform/ydb-go-sdk/v3](https://github.com/ydb-platform/ydb-go-sdk) from 3.108.3 to 3.110.1.
- [Release notes](https://github.com/ydb-platform/ydb-go-sdk/releases)
- [Changelog](https://github.com/ydb-platform/ydb-go-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ydb-platform/ydb-go-sdk/compare/v3.108.3...v3.110.1)

---
updated-dependencies:
- dependency-name: github.com/ydb-platform/ydb-go-sdk/v3
  dependency-version: 3.110.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 10:23:22 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
13103c32d8 chore(deps): bump docker/setup-buildx-action from 3.10.0 to 3.11.0 (#6873)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.10.0 to 3.11.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2...18ce135bb5112fa8ce4ed6c17ab05699d7f3a5e0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 10:23:13 -07:00
Erik JacobsonandGitHub 77397be070 allow distro mount command to use disableXAttr (#6872) 2025-06-16 08:45:05 -07:00
Konstantin LebedevandGitHub 958d88cb85 [shell] volume copy add param noLock (#6871) 2025-06-16 07:39:19 -07:00
chrislu c26299b05a 3.91 2025-06-15 20:42:16 -07:00
NyaMistyandGitHub f894e7b7a5 Support filtering source disk type in volume.tier.upload (#6868) 2025-06-15 20:30:04 -07:00
NyaMistyandGitHub 53e5c84523 Fix wrong error handling in volume.tier.upload when stream == nil but copyErr != nil (#6867) 2025-06-15 20:28:40 -07:00
NyaMistyandGitHub cdc543aa9e Correctly sort in volume.list to ensure output consistency (#6866) 2025-06-15 20:27:48 -07:00
Gerry HernandezandGitHub e653de54b4 FUSE Mount: Fix buffer allocation during copy (#6863)
Fix buffer allocation during FUSE copy
2025-06-13 12:27:39 -07:00
chrislu c79e73aa2a mount: complete fix for freebsd
fix https://github.com/seaweedfs/seaweedfs/issues/6645
2025-06-12 08:19:47 -07:00
chrislu e71d681fee refactor 2025-06-11 20:46:13 -07:00
chrislu 7c4d98446b refactor 2025-06-11 20:13:06 -07:00
chrislu f27e195354 refactoring 2025-06-11 20:13:06 -07:00
Bruce ZouandGitHub fa730abec7 fix rocksdb enumerate (#6858) 2025-06-11 17:55:58 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f7df4856e5 chore(deps): bump github.com/cloudflare/circl from 1.3.7 to 1.6.1 (#6856)
Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.3.7 to 1.6.1.
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.3.7...v1.6.1)

---
updated-dependencies:
- dependency-name: github.com/cloudflare/circl
  dependency-version: 1.6.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-10 19:19:12 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e9c2d5d4f8 chore(deps): bump github.com/redis/go-redis/v9 from 9.8.0 to 9.10.0 (#6850)
Bumps [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) from 9.8.0 to 9.10.0.
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.8.0...v9.10.0)

---
updated-dependencies:
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-09 10:38:36 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6e5e6df326 chore(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.80.0 to 1.80.1 (#6852)
chore(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.80.0 to 1.80.1.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.80.0...service/s3/v1.80.1)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.80.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-09 10:38:22 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f3b1748eb7 chore(deps): bump go.etcd.io/etcd/client/v3 from 3.6.0 to 3.6.1 (#6855)
Bumps [go.etcd.io/etcd/client/v3](https://github.com/etcd-io/etcd) from 3.6.0 to 3.6.1.
- [Release notes](https://github.com/etcd-io/etcd/releases)
- [Commits](https://github.com/etcd-io/etcd/compare/v3.6.0...v3.6.1)

---
updated-dependencies:
- dependency-name: go.etcd.io/etcd/client/v3
  dependency-version: 3.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-09 10:37:57 -07:00
dependabot[bot]andGitHub dcabb71a28 chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials from 1.17.67 to 1.17.68 (#6853) 2025-06-09 09:17:09 -07:00
dependabot[bot]andGitHub 1023971dbc chore(deps): bump modernc.org/sqlite from 1.37.1 to 1.38.0 (#6854) 2025-06-09 09:16:49 -07:00
328 changed files with 48662 additions and 12436 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v1
with:
buildkitd-flags: "--debug"
-
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v1
with:
buildkitd-flags: "--debug"
-
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v1
-
name: Login to Docker Hub
if: github.event_name != 'pull_request'
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v1
-
name: Login to Docker Hub
if: github.event_name != 'pull_request'
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v1
-
name: Login to Docker Hub
if: github.event_name != 'pull_request'
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v1
-
name: Login to Docker Hub
if: github.event_name != 'pull_request'
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v1
-
name: Login to Docker Hub
if: github.event_name != 'pull_request'
+171
View File
@@ -0,0 +1,171 @@
# This workflow will build and deploy the SeaweedFS telemetry server
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Deploy Telemetry Server
on:
workflow_dispatch:
inputs:
setup:
description: 'Run first-time server setup'
required: true
type: boolean
default: false
deploy:
description: 'Deploy telemetry server to remote server'
required: true
type: boolean
default: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Build Telemetry Server
if: github.event_name == 'workflow_dispatch' && inputs.deploy
run: |
go mod tidy
echo "Building telemetry server..."
GOOS=linux GOARCH=amd64 go build -o telemetry-server ./telemetry/server/main.go
ls -la telemetry-server
echo "Build completed successfully"
- name: First-time Server Setup
if: github.event_name == 'workflow_dispatch' && inputs.setup
env:
SSH_PRIVATE_KEY: ${{ secrets.TELEMETRY_SSH_PRIVATE_KEY }}
REMOTE_HOST: ${{ secrets.TELEMETRY_HOST }}
REMOTE_USER: ${{ secrets.TELEMETRY_USER }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "Host *" > ~/.ssh/config
echo " StrictHostKeyChecking no" >> ~/.ssh/config
# Create all required directories with proper permissions
ssh -i ~/.ssh/deploy_key $REMOTE_USER@$REMOTE_HOST "
mkdir -p ~/seaweedfs-telemetry/bin ~/seaweedfs-telemetry/logs ~/seaweedfs-telemetry/data ~/seaweedfs-telemetry/tmp && \
chmod 755 ~/seaweedfs-telemetry/logs && \
chmod 755 ~/seaweedfs-telemetry/data && \
touch ~/seaweedfs-telemetry/logs/telemetry.log ~/seaweedfs-telemetry/logs/telemetry.error.log && \
chmod 644 ~/seaweedfs-telemetry/logs/*.log"
# Create systemd service file
echo "
[Unit]
Description=SeaweedFS Telemetry Server
After=network.target
[Service]
Type=simple
User=$REMOTE_USER
WorkingDirectory=/home/$REMOTE_USER/seaweedfs-telemetry
ExecStart=/home/$REMOTE_USER/seaweedfs-telemetry/bin/telemetry-server -port=8353
Restart=always
RestartSec=5
StandardOutput=append:/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.log
StandardError=append:/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.error.log
[Install]
WantedBy=multi-user.target" > telemetry.service
# Setup logrotate configuration
echo "# SeaweedFS Telemetry service log rotation
/home/$REMOTE_USER/seaweedfs-telemetry/logs/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 644 $REMOTE_USER $REMOTE_USER
postrotate
systemctl restart telemetry.service
endscript
}" > telemetry_logrotate
# Copy configuration files
scp -i ~/.ssh/deploy_key telemetry/grafana-dashboard.json $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
scp -i ~/.ssh/deploy_key telemetry/prometheus.yml $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
# Copy and install service and logrotate files
scp -i ~/.ssh/deploy_key telemetry.service telemetry_logrotate $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
ssh -i ~/.ssh/deploy_key $REMOTE_USER@$REMOTE_HOST "
sudo mv ~/seaweedfs-telemetry/telemetry.service /etc/systemd/system/ && \
sudo mv ~/seaweedfs-telemetry/telemetry_logrotate /etc/logrotate.d/seaweedfs-telemetry && \
sudo systemctl daemon-reload && \
sudo systemctl enable telemetry.service"
echo "✅ First-time setup completed successfully!"
echo "📋 Next step: Run the deployment to install the telemetry server binary"
echo " 1. Go to GitHub Actions → Deploy Telemetry Server"
echo " 2. Click 'Run workflow'"
echo " 3. Check 'Deploy telemetry server to remote server'"
echo " 4. Click 'Run workflow'"
rm -f ~/.ssh/deploy_key
- name: Deploy Telemetry Server to Remote Server
if: github.event_name == 'workflow_dispatch' && inputs.deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.TELEMETRY_SSH_PRIVATE_KEY }}
REMOTE_HOST: ${{ secrets.TELEMETRY_HOST }}
REMOTE_USER: ${{ secrets.TELEMETRY_USER }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "Host *" > ~/.ssh/config
echo " StrictHostKeyChecking no" >> ~/.ssh/config
# Create temp directory and copy binary
ssh -i ~/.ssh/deploy_key $REMOTE_USER@$REMOTE_HOST "mkdir -p ~/seaweedfs-telemetry/tmp"
scp -i ~/.ssh/deploy_key telemetry-server $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/tmp/
# Copy updated configuration files
scp -i ~/.ssh/deploy_key telemetry/grafana-dashboard.json $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
scp -i ~/.ssh/deploy_key telemetry/prometheus.yml $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
# Check if service exists and deploy accordingly
ssh -i ~/.ssh/deploy_key $REMOTE_USER@$REMOTE_HOST "
if systemctl list-unit-files telemetry.service >/dev/null 2>&1; then
echo 'Service exists, performing update...'
sudo systemctl stop telemetry.service
mkdir -p ~/seaweedfs-telemetry/bin
mv ~/seaweedfs-telemetry/tmp/telemetry-server ~/seaweedfs-telemetry/bin/
chmod +x ~/seaweedfs-telemetry/bin/telemetry-server
sudo systemctl start telemetry.service
sudo systemctl status telemetry.service
else
echo 'ERROR: telemetry.service not found!'
echo 'Please run the first-time setup before deploying.'
echo 'Go to GitHub Actions → Deploy Telemetry Server → Run workflow → Check \"Run first-time server setup\"'
exit 1
fi"
# Verify deployment
ssh -i ~/.ssh/deploy_key $REMOTE_USER@$REMOTE_HOST "
echo 'Waiting for service to start...'
sleep 5
curl -f http://localhost:8353/health || echo 'Health check failed'"
rm -f ~/.ssh/deploy_key
- name: Notify Deployment Status
if: always()
run: |
if [ "${{ job.status }}" == "success" ]; then
echo "✅ Telemetry server deployment successful"
echo "Dashboard: http://${{ secrets.TELEMETRY_HOST }}:8353"
echo "Metrics: http://${{ secrets.TELEMETRY_HOST }}:8353/metrics"
else
echo "❌ Telemetry server deployment failed"
fi
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Set up Go 1.x
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v2
uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 # v2
with:
go-version: ^1.13
id: go
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Set up Go 1.x
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v2
uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 # v2
with:
go-version: ^1.13
id: go
+38 -4
View File
@@ -1,19 +1,20 @@
.PHONY: test
.PHONY: test admin-generate admin-build admin-clean admin-dev admin-run admin-test admin-fmt admin-help
BINARY = weed
ADMIN_DIR = weed/admin
SOURCE_DIR = .
debug ?= 0
all: install
install:
install: admin-generate
cd weed; go install
warp_install:
go install github.com/minio/warp@v0.7.6
full_install:
full_install: admin-generate
cd weed; go install -tags "elastic gocdk sqlite ydb tarantool tikv rclone"
server: install
@@ -33,5 +34,38 @@ benchmark: install warp_install
benchmark_with_pprof: debug = 1
benchmark_with_pprof: benchmark
test:
test: admin-generate
cd weed; go test -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v ./...
# Admin component targets
admin-generate:
@echo "Generating admin component templates..."
@cd $(ADMIN_DIR) && $(MAKE) generate
admin-build: admin-generate
@echo "Building admin component..."
@cd $(ADMIN_DIR) && $(MAKE) build
admin-clean:
@echo "Cleaning admin component..."
@cd $(ADMIN_DIR) && $(MAKE) clean
admin-dev:
@echo "Starting admin development server..."
@cd $(ADMIN_DIR) && $(MAKE) dev
admin-run:
@echo "Running admin server..."
@cd $(ADMIN_DIR) && $(MAKE) run
admin-test:
@echo "Testing admin component..."
@cd $(ADMIN_DIR) && $(MAKE) test
admin-fmt:
@echo "Formatting admin component..."
@cd $(ADMIN_DIR) && $(MAKE) fmt
admin-help:
@echo "Admin component help..."
@cd $(ADMIN_DIR) && $(MAKE) help
+8
View File
@@ -73,6 +73,7 @@ Table of Contents
* [Installation Guide](#installation-guide)
* [Disk Related Topics](#disk-related-topics)
* [Benchmark](#benchmark)
* [Enterprise](#enterprise)
* [License](#license)
# Quick Start #
@@ -651,6 +652,13 @@ Total Errors:0.
[Back to TOC](#table-of-contents)
## Enterprise ##
For enterprise users, please visit [seaweedfs.com](https://seaweedfs.com) for the SeaweedFS Enterprise Edition,
which has a self-healing storage format with better data protection.
[Back to TOC](#table-of-contents)
## License ##
Licensed under the Apache License, Version 2.0 (the "License");
+140 -116
View File
@@ -5,9 +5,9 @@ go 1.24
toolchain go1.24.1
require (
cloud.google.com/go v0.121.0 // indirect
cloud.google.com/go v0.121.1 // indirect
cloud.google.com/go/pubsub v1.49.0
cloud.google.com/go/storage v1.54.0
cloud.google.com/go/storage v1.55.0
github.com/Azure/azure-pipeline-go v0.2.3
github.com/Azure/azure-storage-blob-go v0.15.0
github.com/Shopify/sarama v1.38.1
@@ -31,12 +31,12 @@ require (
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-redsync/redsync/v4 v4.13.0
github.com/go-sql-driver/mysql v1.9.2
github.com/go-sql-driver/mysql v1.9.3
github.com/go-zookeeper/zk v1.0.3 // indirect
github.com/gocql/gocql v1.7.0
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4
github.com/golang/snappy v0.0.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/btree v1.1.3
github.com/google/uuid v1.6.0
github.com/google/wire v0.6.0 // indirect
@@ -69,8 +69,8 @@ require (
github.com/posener/complete v1.2.3
github.com/pquerna/cachecontrol v0.2.0
github.com/prometheus/client_golang v1.22.0
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.64.0 // indirect
github.com/prometheus/procfs v0.16.1
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -94,162 +94,180 @@ require (
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.etcd.io/etcd/client/v3 v3.6.0
go.mongodb.org/mongo-driver v1.17.3
go.etcd.io/etcd/client/v3 v3.6.1
go.mongodb.org/mongo-driver v1.17.4
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.41.0
gocloud.dev/pubsub/natspubsub v0.41.0
gocloud.dev v0.42.0
gocloud.dev/pubsub/natspubsub v0.42.0
gocloud.dev/pubsub/rabbitpubsub v0.41.0
golang.org/x/crypto v0.38.0
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/image v0.27.0
golang.org/x/net v0.40.0
golang.org/x/crypto v0.39.0
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476
golang.org/x/image v0.28.0
golang.org/x/net v0.41.0
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.33.0
golang.org/x/text v0.26.0 // indirect
golang.org/x/tools v0.34.0
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.234.0
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/grpc v1.72.1
google.golang.org/api v0.239.0
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0
google.golang.org/protobuf v1.36.6
gopkg.in/inf.v0 v0.9.1 // indirect
modernc.org/b v1.0.0 // indirect
modernc.org/mathutil v1.7.1
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1
modernc.org/sqlite v1.38.0
modernc.org/strutil v1.2.1
)
require (
github.com/Jille/raft-grpc-transport v1.6.1
github.com/a-h/templ v0.3.906
github.com/arangodb/go-driver v1.6.6
github.com/armon/go-metrics v0.4.1
github.com/aws/aws-sdk-go-v2 v1.36.3
github.com/aws/aws-sdk-go-v2/config v1.29.14
github.com/aws/aws-sdk-go-v2/credentials v1.17.67
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.0
github.com/aws/aws-sdk-go-v2 v1.36.5
github.com/aws/aws-sdk-go-v2/config v1.29.17
github.com/aws/aws-sdk-go-v2/credentials v1.17.70
github.com/aws/aws-sdk-go-v2/service/s3 v1.82.0
github.com/cognusion/imaging v1.0.2
github.com/fluent/fluent-logger-golang v1.10.0
github.com/getsentry/sentry-go v0.31.1
github.com/getsentry/sentry-go v0.33.0
github.com/gin-contrib/sessions v1.0.4
github.com/gin-gonic/gin v1.10.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/flatbuffers/go v0.0.0-20230108230133-3b8644d32c50
github.com/hanwen/go-fuse/v2 v2.7.3-0.20250605191109-50f6569d1a7d
github.com/hanwen/go-fuse/v2 v2.8.0
github.com/hashicorp/raft v1.7.3
github.com/hashicorp/raft-boltdb/v2 v2.3.1
github.com/minio/crc64nvme v1.0.1
github.com/minio/crc64nvme v1.0.2
github.com/orcaman/concurrent-map/v2 v2.0.1
github.com/parquet-go/parquet-go v0.24.0
github.com/pkg/sftp v1.13.7
github.com/parquet-go/parquet-go v0.25.1
github.com/pkg/sftp v1.13.9
github.com/rabbitmq/amqp091-go v1.10.0
github.com/rclone/rclone v1.69.3
github.com/rdleal/intervalst v1.4.1
github.com/redis/go-redis/v9 v9.8.0
github.com/rclone/rclone v1.70.2
github.com/rdleal/intervalst v1.5.0
github.com/redis/go-redis/v9 v9.10.0
github.com/schollz/progressbar/v3 v3.18.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/tarantool/go-tarantool/v2 v2.3.2
github.com/tikv/client-go/v2 v2.0.7
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.0
github.com/ydb-platform/ydb-go-sdk/v3 v3.108.3
go.etcd.io/etcd/client/pkg/v3 v3.6.0
github.com/ydb-platform/ydb-go-sdk/v3 v3.111.3
go.etcd.io/etcd/client/pkg/v3 v3.6.1
go.uber.org/atomic v1.11.0
golang.org/x/sync v0.14.0
golang.org/x/sync v0.15.0
google.golang.org/grpc/security/advancedtls v1.0.0
)
require (
cel.dev/expr v0.22.1 // indirect
cloud.google.com/go/auth v0.16.1 // indirect
cel.dev/expr v0.23.0 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.4.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.1 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.97 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.173 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
github.com/IBM/go-sdk-core/v5 v5.20.0 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-crypto v1.1.3 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.7.4 // indirect
github.com/PuerkitoBio/goquery v1.8.1 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/arangodb/go-velocypack v0.0.0-20200318135517-5af53c29c67e // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.77 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sns v1.34.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sqs v1.38.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect
github.com/aws/smithy-go v1.22.4 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
github.com/bradenaw/juniper v0.15.2 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/buengese/sgzip v0.1.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.9.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20241223203758-52b943b88fd6 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.10.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
github.com/creasty/defaults v1.7.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 // indirect
github.com/d4l3k/messagediff v1.2.1 // indirect
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
github.com/ebitengine/purego v0.8.3 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/elastic/gosigar v0.14.2 // indirect
github.com/emersion/go-message v0.18.0 // indirect
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
github.com/geoffgarside/ber v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-resty/resty/v2 v2.11.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/go-openapi/errors v0.22.1 // indirect
github.com/go-openapi/strfmt v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@@ -270,33 +288,35 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lanrat/extsort v1.0.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.40.1 // indirect
github.com/nats-io/nkeys v0.4.10 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncw/swift/v2 v2.0.3 // indirect
github.com/ncw/swift/v2 v2.0.4 // indirect
github.com/nxadm/tail v1.4.11 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/onsi/ginkgo/v2 v2.19.0 // indirect
github.com/onsi/gomega v1.34.1 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/onsi/ginkgo/v2 v2.23.3 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.80.0 // indirect
github.com/panjf2000/ants/v2 v2.9.1 // indirect
github.com/oracle/oci-go-sdk/v65 v65.93.0 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
@@ -307,32 +327,34 @@ require (
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/xattr v0.4.10 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/relvacode/iso8601 v1.3.0 // indirect
github.com/relvacode/iso8601 v1.6.0 // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.47.0 // indirect
github.com/shirou/gopsutil/v4 v4.24.12 // indirect
github.com/samber/lo v1.50.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.5 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/smartystreets/goconvey v1.8.1 // indirect
github.com/sony/gobreaker v0.5.0 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.22 // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.24 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20241213150454-ec0027fb0002 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5 // indirect
github.com/tarantool/go-iproto v1.1.0 // indirect
github.com/tiancaiamao/gp v0.0.0-20221230034425-4025bc8a4d4a // indirect
github.com/tikv/pd/client v0.0.0-20230329114254-1948c247c2b1 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/tklauser/go-sysconf v0.3.13 // indirect
github.com/tklauser/numcpus v0.7.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/twmb/murmur3 v1.1.3 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/unknwon/goconfig v1.0.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
@@ -343,36 +365,38 @@ require (
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 // indirect
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.3 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.etcd.io/bbolt v1.3.10 // indirect
go.etcd.io/etcd/api/v3 v3.6.0 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
go.etcd.io/etcd/api/v3 v3.6.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/libc v1.65.10 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
storj.io/common v0.0.0-20240812101423-26b53789c348 // indirect
storj.io/drpc v0.0.35-0.20240709171858-0075ac871661 // indirect
storj.io/eventkit v0.0.0-20240415002644-1d9596fee086 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
storj.io/common v0.0.0-20250605163628-70ca83b6228e // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
storj.io/infectious v0.0.2 // indirect
storj.io/picobuf v0.0.3 // indirect
storj.io/picobuf v0.0.4 // indirect
storj.io/uplink v1.13.1 // indirect
)
+319 -290
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
apiVersion: v1
description: SeaweedFS
name: seaweedfs
appVersion: "3.90"
appVersion: "3.93"
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
version: 4.0.390
version: 4.0.393
+5
View File
@@ -144,3 +144,8 @@ stringData:
# this key must be an inline json config file
seaweedfs_s3_config: '{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"snu8yoP6QAlY0ne4","secretKey":"PNzBcmeLNEdR0oviwm04NQAicOrDH1Km"}],"actions":["Admin","Read","Write"]},{"name":"anvReadOnly","credentials":[{"accessKey":"SCigFee6c5lbi04A","secretKey":"kgFhbT38R8WUYVtiFQ1OiSVOrYr3NKku"}],"actions":["Read"]}]}'
```
## Enterprise
For enterprise users, please visit [seaweedfs.com](https://seaweedfs.com) for the SeaweedFS Enterprise Edition,
which has a self-healing storage format with better data protection.
@@ -162,6 +162,9 @@ spec:
{{- if .Values.filer.metricsPort }}
-metricsPort={{ .Values.filer.metricsPort }} \
{{- end }}
{{- if .Values.filer.metricsIp }}
-metricsIp={{ .Values.filer.metricsIp }} \
{{- end }}
{{- if .Values.filer.redirectOnRead }}
-redirectOnRead \
{{- end }}
@@ -187,6 +190,7 @@ spec:
-encryptVolumeData \
{{- end }}
-ip=${POD_IP} \
-ip.bind={{ .Values.filer.ipBind }} \
{{- if .Values.filer.filerGroup}}
-filerGroup={{ .Values.filer.filerGroup}} \
{{- end }}
@@ -219,7 +223,10 @@ spec:
-s3.auditLogConfig=/etc/sw/filer_s3_auditLogConfig.json \
{{- end }}
{{- end }}
-master={{ if .Values.global.masterServer }}{{.Values.global.masterServer}}{{ else }}{{ range $index := until (.Values.master.replicas | int) }}${SEAWEEDFS_FULLNAME}-master-{{ $index }}.${SEAWEEDFS_FULLNAME}-master.{{ $.Release.Namespace }}:{{ $.Values.master.port }}{{ if lt $index (sub ($.Values.master.replicas | int) 1) }},{{ end }}{{ end }}{{ end }}
-master={{ if .Values.global.masterServer }}{{.Values.global.masterServer}}{{ else }}{{ range $index := until (.Values.master.replicas | int) }}${SEAWEEDFS_FULLNAME}-master-{{ $index }}.${SEAWEEDFS_FULLNAME}-master.{{ $.Release.Namespace }}:{{ $.Values.master.port }}{{ if lt $index (sub ($.Values.master.replicas | int) 1) }},{{ end }}{{ end }}{{ end }} \
{{- range .Values.filer.extraArgs }}
{{ . }} \
{{- end }}
volumeMounts:
{{- if (or (eq .Values.filer.logs.type "hostPath") (eq .Values.filer.logs.type "persistentVolumeClaim") (eq .Values.filer.logs.type "emptyDir")) }}
- name: seaweedfs-filer-log-volume
@@ -157,18 +157,36 @@ spec:
{{- if .Values.master.metricsPort }}
-metricsPort={{ .Values.master.metricsPort }} \
{{- end }}
{{- if .Values.master.metricsIp }}
-metricsIp={{ .Values.master.metricsIp }} \
{{- end }}
-volumeSizeLimitMB={{ .Values.master.volumeSizeLimitMB }} \
{{- if .Values.master.disableHttp }}
-disableHttp \
{{- end }}
{{- if .Values.master.pulseSeconds }}
-pulseSeconds={{ .Values.master.pulseSeconds }} \
{{- if .Values.master.resumeState }}
-resumeState \
{{- end }}
{{- if .Values.master.raftHashicorp }}
-raftHashicorp \
{{- end }}
{{- if .Values.master.raftBootstrap }}
-raftBootstrap \
{{- end }}
{{- if .Values.master.electionTimeout }}
-electionTimeout={{ .Values.master.electionTimeout }} \
{{- end }}
{{- if .Values.master.heartbeatInterval }}
-heartbeatInterval={{ .Values.master.heartbeatInterval }} \
{{- end }}
{{- if .Values.master.garbageThreshold }}
-garbageThreshold={{ .Values.master.garbageThreshold }} \
{{- end }}
-ip=${POD_NAME}.${SEAWEEDFS_FULLNAME}-master.{{ .Release.Namespace }} \
-peers={{ range $index := until (.Values.master.replicas | int) }}${SEAWEEDFS_FULLNAME}-master-{{ $index }}.${SEAWEEDFS_FULLNAME}-master.{{ $.Release.Namespace }}:{{ $.Values.master.port }}{{ if lt $index (sub ($.Values.master.replicas | int) 1) }},{{ end }}{{ end }}
-peers={{ range $index := until (.Values.master.replicas | int) }}${SEAWEEDFS_FULLNAME}-master-{{ $index }}.${SEAWEEDFS_FULLNAME}-master.{{ $.Release.Namespace }}:{{ $.Values.master.port }}{{ if lt $index (sub ($.Values.master.replicas | int) 1) }},{{ end }}{{ end }} \
{{- range .Values.master.extraArgs }}
{{ . }} \
{{- end }}
volumeMounts:
- name : data-{{ .Release.Namespace }}
mountPath: /data
@@ -150,6 +150,9 @@ spec:
{{- if .Values.volume.metricsPort }}
-metricsPort={{ .Values.volume.metricsPort }} \
{{- end }}
{{- if .Values.volume.metricsIp }}
-metricsIp={{ .Values.volume.metricsIp }} \
{{- end }}
-dir {{range $index, $dir := .Values.volume.dataDirs }}{{if ne $index 0}},{{end}}/{{$dir.name}}{{end}} \
{{- if .Values.volume.idx }}
-dir.idx=/idx \
@@ -183,7 +186,10 @@ spec:
-minFreeSpacePercent={{ .Values.volume.minFreeSpacePercent }} \
-ip=${POD_NAME}.${SEAWEEDFS_FULLNAME}-volume.{{ .Release.Namespace }} \
-compactionMBps={{ .Values.volume.compactionMBps }} \
-mserver={{ if .Values.global.masterServer }}{{.Values.global.masterServer}}{{ else }}{{ range $index := until (.Values.master.replicas | int) }}${SEAWEEDFS_FULLNAME}-master-{{ $index }}.${SEAWEEDFS_FULLNAME}-master.{{ $.Release.Namespace }}:{{ $.Values.master.port }}{{ if lt $index (sub ($.Values.master.replicas | int) 1) }},{{ end }}{{ end }}{{ end }}
-mserver={{ if .Values.global.masterServer }}{{.Values.global.masterServer}}{{ else }}{{ range $index := until (.Values.master.replicas | int) }}${SEAWEEDFS_FULLNAME}-master-{{ $index }}.${SEAWEEDFS_FULLNAME}-master.{{ $.Release.Namespace }}:{{ $.Values.master.port }}{{ if lt $index (sub ($.Values.master.replicas | int) 1) }},{{ end }}{{ end }}{{ end }} \
{{- range .Values.volume.extraArgs }}
{{ . }} \
{{- end }}
volumeMounts:
{{- range $dir := .Values.volume.dataDirs }}
{{- if not ( eq $dir.type "custom" ) }}
+37 -2
View File
@@ -56,12 +56,11 @@ master:
port: 9333
grpcPort: 19333
metricsPort: 9327
metricsIp: "" # Metrics listen IP. If empty, defaults to ipBind
ipBind: "0.0.0.0"
volumePreallocate: false
volumeSizeLimitMB: 1000
loggingOverrideLevel: null
# number of seconds between heartbeats, default 5
pulseSeconds: null
# threshold to vacuum and reclaim spaces, default 0.3 (30%)
garbageThreshold: null
# Prometheus push interval in seconds, default 15
@@ -75,6 +74,25 @@ master:
# Disable http request, only gRpc operations are allowed
disableHttp: false
# Resume previous state on start master server
resumeState: false
# Use Hashicorp Raft
raftHashicorp: false
# Whether to bootstrap the Raft cluster. Only use it when use Hashicorp Raft
raftBootstrap: false
# election timeout of master servers
electionTimeout: "10s"
# heartbeat interval of master servers, and will be randomly multiplied by [1, 1.25)
heartbeatInterval: "300ms"
# Custom command line arguments to add to the master command
# Example to fix IPv6 metrics connectivity issues:
# extraArgs: ["-metricsIp", "0.0.0.0"]
# Example with multiple args:
# extraArgs: ["-customFlag", "value", "-anotherFlag"]
extraArgs: []
config: |-
# Enter any extra configuration for master.toml here.
# It may be a multi-line string.
@@ -277,6 +295,7 @@ volume:
port: 8080
grpcPort: 18080
metricsPort: 9327
metricsIp: "" # Metrics listen IP. If empty, defaults to ipBind
ipBind: "0.0.0.0"
replicas: 1
loggingOverrideLevel: null
@@ -289,6 +308,13 @@ volume:
# minimum free disk space(in percents). If free disk space lower this value - all volumes marks as ReadOnly
minFreeSpacePercent: 7
# Custom command line arguments to add to the volume command
# Example to fix IPv6 metrics connectivity issues:
# extraArgs: ["-metricsIp", "0.0.0.0"]
# Example with multiple args:
# extraArgs: ["-customFlag", "value", "-anotherFlag"]
extraArgs: []
# For each data disk you may use ANY storage-class, example with local-path-provisioner
# Annotations are optional.
# dataDirs:
@@ -520,6 +546,8 @@ filer:
port: 8888
grpcPort: 18888
metricsPort: 9327
metricsIp: "" # Metrics listen IP. If empty, defaults to ipBind
ipBind: "0.0.0.0" # IP address to bind to. Set to 0.0.0.0 to allow external traffic
loggingOverrideLevel: null
filerGroup: ""
# prefer to read and write to volumes in this data center (not set by default)
@@ -547,6 +575,13 @@ filer:
# Disable http request, only gRpc operations are allowed
disableHttp: false
# Custom command line arguments to add to the filer command
# Example to fix IPv6 metrics connectivity issues:
# extraArgs: ["-metricsIp", "0.0.0.0"]
# Example with multiple args:
# extraArgs: ["-customFlag", "value", "-anotherFlag"]
extraArgs: []
# Add a custom notification.toml to configure filer notifications
# Example:
# notificationConfig: |-
@@ -5,7 +5,6 @@ import com.moandjiezana.toml.Toml;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory;
import org.apache.http.ssl.SSLContextBuilder;
import org.apache.http.ssl.SSLContexts;
import org.slf4j.Logger;
+271
View File
@@ -0,0 +1,271 @@
# SeaweedFS Telemetry Server Deployment
This document describes how to deploy the SeaweedFS telemetry server to a remote server using GitHub Actions.
## Prerequisites
1. A remote Linux server with:
- SSH access
- systemd (for service management)
- Optional: Prometheus and Grafana (for monitoring)
2. GitHub repository secrets configured (see [Setup GitHub Secrets](#setup-github-secrets) below):
- `TELEMETRY_SSH_PRIVATE_KEY`: SSH private key for accessing the remote server
- `TELEMETRY_HOST`: Remote server hostname or IP address
- `TELEMETRY_USER`: Username for SSH access
## Setup GitHub Secrets
Before using the deployment workflow, you need to configure the required secrets in your GitHub repository.
### Step 1: Generate SSH Key Pair
On your local machine, generate a new SSH key pair specifically for deployment:
```bash
# Generate a new SSH key pair
ssh-keygen -t ed25519 -C "seaweedfs-telemetry-deploy" -f ~/.ssh/seaweedfs_telemetry_deploy
# This creates two files:
# ~/.ssh/seaweedfs_telemetry_deploy (private key)
# ~/.ssh/seaweedfs_telemetry_deploy.pub (public key)
```
### Step 2: Configure Remote Server
Copy the public key to your remote server:
```bash
# Copy public key to remote server
ssh-copy-id -i ~/.ssh/seaweedfs_telemetry_deploy.pub user@your-server.com
# Or manually append to authorized_keys
cat ~/.ssh/seaweedfs_telemetry_deploy.pub | ssh user@your-server.com "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
```
Test the SSH connection:
```bash
# Test SSH connection with the new key
ssh -i ~/.ssh/seaweedfs_telemetry_deploy user@your-server.com "echo 'SSH connection successful'"
```
### Step 3: Add Secrets to GitHub Repository
1. Go to your GitHub repository
2. Click on **Settings** tab
3. In the sidebar, click **Secrets and variables** → **Actions**
4. Click **New repository secret** for each of the following:
#### TELEMETRY_SSH_PRIVATE_KEY
```bash
# Display the private key content
cat ~/.ssh/seaweedfs_telemetry_deploy
```
- **Name**: `TELEMETRY_SSH_PRIVATE_KEY`
- **Value**: Copy the entire private key content, including the `-----BEGIN OPENSSH PRIVATE KEY-----` and `-----END OPENSSH PRIVATE KEY-----` lines
#### TELEMETRY_HOST
- **Name**: `TELEMETRY_HOST`
- **Value**: Your server's hostname or IP address (e.g., `telemetry.example.com` or `192.168.1.100`)
#### TELEMETRY_USER
- **Name**: `TELEMETRY_USER`
- **Value**: The username on the remote server (e.g., `ubuntu`, `deploy`, or your username)
### Step 4: Verify Configuration
Create a simple test workflow or manually trigger the deployment to verify the secrets are working correctly.
### Security Best Practices
1. **Dedicated SSH Key**: Use a separate SSH key only for deployment
2. **Limited Permissions**: Create a dedicated user on the remote server with minimal required permissions
3. **Key Rotation**: Regularly rotate SSH keys
4. **Server Access**: Restrict SSH access to specific IP ranges if possible
### Example Server Setup
If you're setting up a new server, here's a basic configuration:
```bash
# On the remote server, create a dedicated user for deployment
sudo useradd -m -s /bin/bash seaweedfs-deploy
sudo usermod -aG sudo seaweedfs-deploy # Only if sudo access is needed
# Switch to the deployment user
sudo su - seaweedfs-deploy
# Create SSH directory
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# Add your public key (paste the content of seaweedfs_telemetry_deploy.pub)
nano ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
```
### Troubleshooting
#### SSH Connection Issues
```bash
# Test SSH connection manually
ssh -i ~/.ssh/seaweedfs_telemetry_deploy -v user@your-server.com
# Check SSH key permissions
ls -la ~/.ssh/seaweedfs_telemetry_deploy*
# Should show: -rw------- for private key, -rw-r--r-- for public key
```
#### GitHub Actions Fails
1. **Check secrets**: Ensure all three secrets are properly set in GitHub
2. **Verify SSH key**: Make sure the entire private key (including headers/footers) is copied
3. **Test connectivity**: Manually SSH to the server from your local machine
4. **Check user permissions**: Ensure the remote user has necessary permissions
## GitHub Actions Workflow
The deployment workflow (`.github/workflows/deploy_telemetry.yml`) provides two main operations:
### 1. First-time Setup
Run this once to set up the remote server:
1. Go to GitHub Actions in your repository
2. Select "Deploy Telemetry Server" workflow
3. Click "Run workflow"
4. Check "Run first-time server setup"
5. Click "Run workflow"
This will:
- Create necessary directories on the remote server
- Set up systemd service configuration
- Configure log rotation
- Upload Grafana dashboard and Prometheus configuration
- Enable the telemetry service (but not start it yet)
**Note**: The setup only prepares the infrastructure. You need to run a deployment afterward to install and start the telemetry server.
### 2. Deploy Updates
To deploy updates, manually trigger deployment:
1. Go to GitHub Actions in your repository
2. Select "Deploy Telemetry Server" workflow
3. Click "Run workflow"
4. Check "Deploy telemetry server to remote server"
5. Click "Run workflow"
## Server Directory Structure
After setup, the remote server will have:
```
~/seaweedfs-telemetry/
├── bin/
│ └── telemetry-server # Binary executable
├── logs/
│ ├── telemetry.log # Application logs
│ └── telemetry.error.log # Error logs
├── data/ # Data directory (if needed)
├── grafana-dashboard.json # Grafana dashboard configuration
└── prometheus.yml # Prometheus configuration
```
## Service Management
The telemetry server runs as a systemd service:
```bash
# Check service status
sudo systemctl status telemetry.service
# View logs
sudo journalctl -u telemetry.service -f
# Restart service
sudo systemctl restart telemetry.service
# Stop/start service
sudo systemctl stop telemetry.service
sudo systemctl start telemetry.service
```
## Accessing the Service
After deployment, the telemetry server will be available at:
- **Dashboard**: `http://your-server:8353`
- **API**: `http://your-server:8353/api/*`
- **Metrics**: `http://your-server:8353/metrics`
- **Health Check**: `http://your-server:8353/health`
## Optional: Prometheus and Grafana Integration
### Prometheus Setup
1. Install Prometheus on your server
2. Update `/etc/prometheus/prometheus.yml` to include:
```yaml
scrape_configs:
- job_name: 'seaweedfs-telemetry'
static_configs:
- targets: ['localhost:8353']
metrics_path: '/metrics'
```
### Grafana Setup
1. Install Grafana on your server
2. Import the dashboard from `~/seaweedfs-telemetry/grafana-dashboard.json`
3. Configure Prometheus as a data source pointing to your Prometheus instance
## Troubleshooting
### Deployment Fails
1. Check GitHub Actions logs for detailed error messages
2. Verify SSH connectivity: `ssh user@host`
3. Ensure all required secrets are configured in GitHub
### Service Won't Start
1. Check service logs: `sudo journalctl -u telemetry.service`
2. Verify binary permissions: `ls -la ~/seaweedfs-telemetry/bin/`
3. Test binary manually: `~/seaweedfs-telemetry/bin/telemetry-server -help`
### Port Conflicts
If port 8353 is already in use:
1. Edit the systemd service: `sudo systemctl edit telemetry.service`
2. Add override configuration:
```ini
[Service]
ExecStart=
ExecStart=/home/user/seaweedfs-telemetry/bin/telemetry-server -port=8354
```
3. Reload and restart: `sudo systemctl daemon-reload && sudo systemctl restart telemetry.service`
## Security Considerations
1. **Firewall**: Consider restricting access to telemetry ports
2. **SSH Keys**: Use dedicated SSH keys with minimal permissions
3. **User Permissions**: Run the service as a non-privileged user
4. **Network**: Consider running on internal networks only
## Monitoring
Monitor the deployment and service health:
- **GitHub Actions**: Check workflow runs for deployment status
- **System Logs**: `sudo journalctl -u telemetry.service`
- **Application Logs**: `tail -f ~/seaweedfs-telemetry/logs/telemetry.log`
- **Health Endpoint**: `curl http://localhost:8353/health`
- **Metrics**: `curl http://localhost:8353/metrics`
+353
View File
@@ -0,0 +1,353 @@
# SeaweedFS Telemetry System
A privacy-respecting telemetry system for SeaweedFS that collects cluster-level usage statistics and provides visualization through Prometheus and Grafana.
## Features
- **Privacy-First Design**: Uses in-memory cluster IDs (regenerated on restart), no personal data collection
- **Prometheus Integration**: Native Prometheus metrics for monitoring and alerting
- **Grafana Dashboards**: Pre-built dashboards for data visualization
- **Protocol Buffers**: Efficient binary data transmission for optimal performance
- **Opt-in Only**: Disabled by default, requires explicit configuration
- **Docker Compose**: Complete monitoring stack deployment
- **Automatic Cleanup**: Configurable data retention policies
## Architecture
```
SeaweedFS Cluster → Telemetry Client → Telemetry Server → Prometheus → Grafana
(protobuf) (metrics) (queries)
```
## Data Transmission
The telemetry system uses **Protocol Buffers exclusively** for efficient binary data transmission:
- **Compact Format**: 30-50% smaller than JSON
- **Fast Serialization**: Better performance than text-based formats
- **Type Safety**: Strong typing with generated Go structs
- **Schema Evolution**: Built-in versioning support
### Protobuf Schema
```protobuf
message TelemetryData {
string cluster_id = 1; // In-memory generated UUID
string version = 2; // SeaweedFS version
string os = 3; // Operating system
// Field 4 reserved (was features)
// Field 5 reserved (was deployment)
int32 volume_server_count = 6; // Number of volume servers
uint64 total_disk_bytes = 7; // Total disk usage
int32 total_volume_count = 8; // Total volume count
int32 filer_count = 9; // Number of filer servers
int32 broker_count = 10; // Number of broker servers
int64 timestamp = 11; // Collection timestamp
}
```
## Privacy Approach
- **No Personal Data**: No hostnames, IP addresses, or user information
- **In-Memory IDs**: Cluster IDs are generated in-memory and change on restart
- **Aggregated Data**: Only cluster-level statistics, no individual file/user data
- **Opt-in Only**: Telemetry is disabled by default
- **Transparent**: Open source implementation, clear data collection policy
## Collected Data
| Field | Description | Example |
|-------|-------------|---------|
| `cluster_id` | In-memory UUID (changes on restart) | `a1b2c3d4-...` |
| `version` | SeaweedFS version | `3.45` |
| `os` | Operating system and architecture | `linux/amd64` |
| `volume_server_count` | Number of volume servers | `5` |
| `total_disk_bytes` | Total disk usage across cluster | `1073741824` |
| `total_volume_count` | Total number of volumes | `120` |
| `filer_count` | Number of filer servers | `2` |
| `broker_count` | Number of broker servers | `1` |
| `timestamp` | When data was collected | `1640995200` |
## Quick Start
### 1. Deploy Telemetry Server
```bash
# Clone and start the complete monitoring stack
git clone https://github.com/seaweedfs/seaweedfs.git
cd seaweedfs/telemetry
docker-compose up -d
# Or run the server directly
cd server
go run . -port=8080 -dashboard=true
```
### 2. Configure SeaweedFS
```bash
# Enable telemetry in SeaweedFS master (uses default telemetry.seaweedfs.com)
weed master -telemetry=true
# Or in server mode
weed server -telemetry=true
# Or specify custom telemetry server
weed master -telemetry=true -telemetry.url=http://localhost:8080/api/collect
```
### 3. Access Dashboards
- **Telemetry Server**: http://localhost:8080
- **Prometheus**: http://localhost:9090
- **Grafana**: http://localhost:3000 (admin/admin)
## Configuration
### SeaweedFS Master/Server
```bash
# Enable telemetry
-telemetry=true
# Set custom telemetry server URL (optional, defaults to telemetry.seaweedfs.com)
-telemetry.url=http://your-telemetry-server:8080/api/collect
```
### Telemetry Server
```bash
# Server configuration
-port=8080 # Server port
-dashboard=true # Enable built-in dashboard
-cleanup=24h # Cleanup interval
-max-age=720h # Maximum data retention (30 days)
# Example
./telemetry-server -port=8080 -dashboard=true -cleanup=24h -max-age=720h
```
## Prometheus Metrics
The telemetry server exposes these Prometheus metrics:
### Cluster Metrics
- `seaweedfs_telemetry_total_clusters`: Total unique clusters (30 days)
- `seaweedfs_telemetry_active_clusters`: Active clusters (7 days)
### Per-Cluster Metrics
- `seaweedfs_telemetry_volume_servers{cluster_id, version, os}`: Volume servers per cluster
- `seaweedfs_telemetry_disk_bytes{cluster_id, version, os}`: Disk usage per cluster
- `seaweedfs_telemetry_volume_count{cluster_id, version, os}`: Volume count per cluster
- `seaweedfs_telemetry_filer_count{cluster_id, version, os}`: Filer servers per cluster
- `seaweedfs_telemetry_broker_count{cluster_id, version, os}`: Broker servers per cluster
- `seaweedfs_telemetry_cluster_info{cluster_id, version, os}`: Cluster metadata
### Server Metrics
- `seaweedfs_telemetry_reports_received_total`: Total telemetry reports received
## API Endpoints
### Data Collection
```bash
# Submit telemetry data (protobuf only)
POST /api/collect
Content-Type: application/x-protobuf
[TelemetryRequest protobuf data]
```
### Statistics (JSON for dashboard/debugging)
```bash
# Get aggregated statistics
GET /api/stats
# Get recent cluster instances
GET /api/instances?limit=100
# Get metrics over time
GET /api/metrics?days=30
```
### Monitoring
```bash
# Prometheus metrics
GET /metrics
```
## Docker Deployment
### Complete Stack (Recommended)
```yaml
# docker-compose.yml
version: '3.8'
services:
telemetry-server:
build: ./server
ports:
- "8080:8080"
command: ["-port=8080", "-dashboard=true", "-cleanup=24h"]
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- ./grafana-provisioning:/etc/grafana/provisioning
- ./grafana-dashboard.json:/var/lib/grafana/dashboards/seaweedfs.json
```
```bash
# Deploy the stack
docker-compose up -d
# Scale telemetry server if needed
docker-compose up -d --scale telemetry-server=3
```
### Server Only
```bash
# Build and run telemetry server
cd server
docker build -t seaweedfs-telemetry .
docker run -p 8080:8080 seaweedfs-telemetry -port=8080 -dashboard=true
```
## Development
### Protocol Buffer Development
```bash
# Generate protobuf code
cd telemetry
protoc --go_out=. --go_opt=paths=source_relative proto/telemetry.proto
# The generated code is already included in the repository
```
### Build from Source
```bash
# Build telemetry server
cd telemetry/server
go build -o telemetry-server .
# Build SeaweedFS with telemetry support
cd ../..
go build -o weed ./weed
```
### Testing
```bash
# Test telemetry server
cd telemetry/server
go test ./...
# Test protobuf communication (requires protobuf tools)
# See telemetry client code for examples
```
## Grafana Dashboard
The included Grafana dashboard provides:
- **Overview**: Total and active clusters, version distribution
- **Resource Usage**: Volume servers and disk usage over time
- **Infrastructure**: Operating system distribution and server counts
- **Growth Trends**: Historical growth patterns
### Custom Queries
```promql
# Total active clusters
seaweedfs_telemetry_active_clusters
# Disk usage by version
sum by (version) (seaweedfs_telemetry_disk_bytes)
# Volume servers by operating system
sum by (os) (seaweedfs_telemetry_volume_servers)
# Filer servers by version
sum by (version) (seaweedfs_telemetry_filer_count)
# Broker servers across all clusters
sum(seaweedfs_telemetry_broker_count)
# Growth rate (weekly)
increase(seaweedfs_telemetry_total_clusters[7d])
```
## Security Considerations
- **Network Security**: Use HTTPS in production environments
- **Access Control**: Implement authentication for Grafana and Prometheus
- **Data Retention**: Configure appropriate retention policies
- **Monitoring**: Monitor the telemetry infrastructure itself
## Troubleshooting
### Common Issues
**SeaweedFS not sending data:**
```bash
# Check telemetry configuration
weed master -h | grep telemetry
# Verify connectivity
curl -v http://your-telemetry-server:8080/api/collect
```
**Server not receiving data:**
```bash
# Check server logs
docker-compose logs telemetry-server
# Verify metrics endpoint
curl http://localhost:8080/metrics
```
**Prometheus not scraping:**
```bash
# Check Prometheus targets
curl http://localhost:9090/api/v1/targets
# Verify configuration
docker-compose logs prometheus
```
### Debugging
```bash
# Enable verbose logging in SeaweedFS
weed master -v=2 -telemetry=true
# Check telemetry server metrics
curl http://localhost:8080/metrics | grep seaweedfs_telemetry
# Test data flow
curl http://localhost:8080/api/stats
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## License
This telemetry system is part of SeaweedFS and follows the same Apache 2.0 license.
+55
View File
@@ -0,0 +1,55 @@
version: '3.8'
services:
telemetry-server:
build: ./server
ports:
- "8080:8080"
command: [
"./telemetry-server",
"-port=8080",
"-dashboard=false", # Disable built-in dashboard, use Grafana
"-log=true",
"-cors=true"
]
networks:
- telemetry
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
- '--storage.tsdb.retention.time=200h'
- '--web.enable-lifecycle'
networks:
- telemetry
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana_data:/var/lib/grafana
- ./grafana-dashboard.json:/var/lib/grafana/dashboards/seaweedfs-telemetry.json
- ./grafana-provisioning:/etc/grafana/provisioning
networks:
- telemetry
volumes:
prometheus_data:
grafana_data:
networks:
telemetry:
driver: bridge
+734
View File
@@ -0,0 +1,734 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"showHeader": true
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "seaweedfs_telemetry_total_clusters",
"format": "time_series",
"refId": "A"
}
],
"title": "Total SeaweedFS Clusters",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"showHeader": true
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "seaweedfs_telemetry_active_clusters",
"format": "time_series",
"refId": "A"
}
],
"title": "Active Clusters (7 days)",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
}
},
"mappings": []
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"displayMode": "visible",
"placement": "bottom",
"showLegend": true
},
"pieType": "pie",
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "count by (version) (seaweedfs_telemetry_cluster_info)",
"format": "time_series",
"legendFormat": "{{version}}",
"refId": "A"
}
],
"title": "SeaweedFS Version Distribution",
"type": "piechart"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
}
},
"mappings": []
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"legend": {
"displayMode": "visible",
"placement": "bottom",
"showLegend": true
},
"pieType": "pie",
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "count by (os) (seaweedfs_telemetry_cluster_info)",
"format": "time_series",
"legendFormat": "{{os}}",
"refId": "A"
}
],
"title": "Operating System Distribution",
"type": "piechart"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(seaweedfs_telemetry_volume_servers)",
"format": "time_series",
"legendFormat": "Total Volume Servers",
"refId": "A"
}
],
"title": "Total Volume Servers Over Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 24
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(seaweedfs_telemetry_disk_bytes)",
"format": "time_series",
"legendFormat": "Total Disk Usage",
"refId": "A"
}
],
"title": "Total Disk Usage Over Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 24
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(seaweedfs_telemetry_volume_count)",
"format": "time_series",
"legendFormat": "Total Volume Count",
"refId": "A"
}
],
"title": "Total Volume Count Over Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 32
},
"id": 8,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(seaweedfs_telemetry_filer_count)",
"format": "time_series",
"legendFormat": "Total Filer Count",
"refId": "A"
}
],
"title": "Total Filer Servers Over Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 32
},
"id": 9,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(seaweedfs_telemetry_broker_count)",
"format": "time_series",
"legendFormat": "Total Broker Count",
"refId": "A"
}
],
"title": "Total Broker Servers Over Time",
"type": "timeseries"
}
],
"refresh": "5m",
"schemaVersion": 38,
"style": "dark",
"tags": [
"seaweedfs",
"telemetry"
],
"templating": {
"list": []
},
"time": {
"from": "now-24h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "SeaweedFS Telemetry Dashboard",
"uid": "seaweedfs-telemetry",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: 'seaweedfs'
orgId: 1
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
+15
View File
@@ -0,0 +1,15 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
# - "first_rules.yml"
# - "second_rules.yml"
scrape_configs:
- job_name: 'seaweedfs-telemetry'
static_configs:
- targets: ['telemetry-server:8080']
scrape_interval: 30s
metrics_path: '/metrics'
scrape_timeout: 10s
+377
View File
@@ -0,0 +1,377 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.34.2
// protoc v5.29.3
// source: telemetry.proto
package proto
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// TelemetryData represents cluster-level telemetry information
type TelemetryData struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Unique cluster identifier (generated in-memory)
ClusterId string `protobuf:"bytes,1,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"`
// SeaweedFS version
Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"`
// Operating system (e.g., "linux/amd64")
Os string `protobuf:"bytes,3,opt,name=os,proto3" json:"os,omitempty"`
// Number of volume servers in the cluster
VolumeServerCount int32 `protobuf:"varint,6,opt,name=volume_server_count,json=volumeServerCount,proto3" json:"volume_server_count,omitempty"`
// Total disk usage across all volume servers (in bytes)
TotalDiskBytes uint64 `protobuf:"varint,7,opt,name=total_disk_bytes,json=totalDiskBytes,proto3" json:"total_disk_bytes,omitempty"`
// Total number of volumes in the cluster
TotalVolumeCount int32 `protobuf:"varint,8,opt,name=total_volume_count,json=totalVolumeCount,proto3" json:"total_volume_count,omitempty"`
// Number of filer servers in the cluster
FilerCount int32 `protobuf:"varint,9,opt,name=filer_count,json=filerCount,proto3" json:"filer_count,omitempty"`
// Number of broker servers in the cluster
BrokerCount int32 `protobuf:"varint,10,opt,name=broker_count,json=brokerCount,proto3" json:"broker_count,omitempty"`
// Unix timestamp when the data was collected
Timestamp int64 `protobuf:"varint,11,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
}
func (x *TelemetryData) Reset() {
*x = TelemetryData{}
if protoimpl.UnsafeEnabled {
mi := &file_telemetry_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *TelemetryData) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TelemetryData) ProtoMessage() {}
func (x *TelemetryData) ProtoReflect() protoreflect.Message {
mi := &file_telemetry_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TelemetryData.ProtoReflect.Descriptor instead.
func (*TelemetryData) Descriptor() ([]byte, []int) {
return file_telemetry_proto_rawDescGZIP(), []int{0}
}
func (x *TelemetryData) GetClusterId() string {
if x != nil {
return x.ClusterId
}
return ""
}
func (x *TelemetryData) GetVersion() string {
if x != nil {
return x.Version
}
return ""
}
func (x *TelemetryData) GetOs() string {
if x != nil {
return x.Os
}
return ""
}
func (x *TelemetryData) GetVolumeServerCount() int32 {
if x != nil {
return x.VolumeServerCount
}
return 0
}
func (x *TelemetryData) GetTotalDiskBytes() uint64 {
if x != nil {
return x.TotalDiskBytes
}
return 0
}
func (x *TelemetryData) GetTotalVolumeCount() int32 {
if x != nil {
return x.TotalVolumeCount
}
return 0
}
func (x *TelemetryData) GetFilerCount() int32 {
if x != nil {
return x.FilerCount
}
return 0
}
func (x *TelemetryData) GetBrokerCount() int32 {
if x != nil {
return x.BrokerCount
}
return 0
}
func (x *TelemetryData) GetTimestamp() int64 {
if x != nil {
return x.Timestamp
}
return 0
}
// TelemetryRequest is sent from SeaweedFS clusters to the telemetry server
type TelemetryRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Data *TelemetryData `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
}
func (x *TelemetryRequest) Reset() {
*x = TelemetryRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_telemetry_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *TelemetryRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TelemetryRequest) ProtoMessage() {}
func (x *TelemetryRequest) ProtoReflect() protoreflect.Message {
mi := &file_telemetry_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TelemetryRequest.ProtoReflect.Descriptor instead.
func (*TelemetryRequest) Descriptor() ([]byte, []int) {
return file_telemetry_proto_rawDescGZIP(), []int{1}
}
func (x *TelemetryRequest) GetData() *TelemetryData {
if x != nil {
return x.Data
}
return nil
}
// TelemetryResponse is returned by the telemetry server
type TelemetryResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
}
func (x *TelemetryResponse) Reset() {
*x = TelemetryResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_telemetry_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *TelemetryResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TelemetryResponse) ProtoMessage() {}
func (x *TelemetryResponse) ProtoReflect() protoreflect.Message {
mi := &file_telemetry_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TelemetryResponse.ProtoReflect.Descriptor instead.
func (*TelemetryResponse) Descriptor() ([]byte, []int) {
return file_telemetry_proto_rawDescGZIP(), []int{2}
}
func (x *TelemetryResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *TelemetryResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
var File_telemetry_proto protoreflect.FileDescriptor
var file_telemetry_proto_rawDesc = []byte{
0x0a, 0x0f, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x12, 0x09, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x22, 0xce, 0x02, 0x0a,
0x0d, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x44, 0x61, 0x74, 0x61, 0x12, 0x1d,
0x0a, 0x0a, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x09, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a,
0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x73, 0x18, 0x03, 0x20,
0x01, 0x28, 0x09, 0x52, 0x02, 0x6f, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x76, 0x6f, 0x6c, 0x75, 0x6d,
0x65, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06,
0x20, 0x01, 0x28, 0x05, 0x52, 0x11, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76,
0x65, 0x72, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x74, 0x61, 0x6c,
0x5f, 0x64, 0x69, 0x73, 0x6b, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28,
0x04, 0x52, 0x0e, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x44, 0x69, 0x73, 0x6b, 0x42, 0x79, 0x74, 0x65,
0x73, 0x12, 0x2c, 0x0a, 0x12, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x76, 0x6f, 0x6c, 0x75, 0x6d,
0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x10, 0x74,
0x6f, 0x74, 0x61, 0x6c, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12,
0x1f, 0x0a, 0x0b, 0x66, 0x69, 0x6c, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x09,
0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x72, 0x43, 0x6f, 0x75, 0x6e, 0x74,
0x12, 0x21, 0x0a, 0x0c, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x72, 0x43, 0x6f,
0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
0x70, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, 0x40, 0x0a,
0x10, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x2c, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x18, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x54, 0x65, 0x6c, 0x65,
0x6d, 0x65, 0x74, 0x72, 0x79, 0x44, 0x61, 0x74, 0x61, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22,
0x47, 0x0a, 0x11, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18,
0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x18,
0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x30, 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68,
0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, 0x65, 0x61, 0x77, 0x65, 0x65, 0x64, 0x66, 0x73,
0x2f, 0x73, 0x65, 0x61, 0x77, 0x65, 0x65, 0x64, 0x66, 0x73, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x6d,
0x65, 0x74, 0x72, 0x79, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x33,
}
var (
file_telemetry_proto_rawDescOnce sync.Once
file_telemetry_proto_rawDescData = file_telemetry_proto_rawDesc
)
func file_telemetry_proto_rawDescGZIP() []byte {
file_telemetry_proto_rawDescOnce.Do(func() {
file_telemetry_proto_rawDescData = protoimpl.X.CompressGZIP(file_telemetry_proto_rawDescData)
})
return file_telemetry_proto_rawDescData
}
var file_telemetry_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_telemetry_proto_goTypes = []any{
(*TelemetryData)(nil), // 0: telemetry.TelemetryData
(*TelemetryRequest)(nil), // 1: telemetry.TelemetryRequest
(*TelemetryResponse)(nil), // 2: telemetry.TelemetryResponse
}
var file_telemetry_proto_depIdxs = []int32{
0, // 0: telemetry.TelemetryRequest.data:type_name -> telemetry.TelemetryData
1, // [1:1] is the sub-list for method output_type
1, // [1:1] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_telemetry_proto_init() }
func file_telemetry_proto_init() {
if File_telemetry_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_telemetry_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*TelemetryData); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_telemetry_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*TelemetryRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_telemetry_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*TelemetryResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_telemetry_proto_rawDesc,
NumEnums: 0,
NumMessages: 3,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_telemetry_proto_goTypes,
DependencyIndexes: file_telemetry_proto_depIdxs,
MessageInfos: file_telemetry_proto_msgTypes,
}.Build()
File_telemetry_proto = out.File
file_telemetry_proto_rawDesc = nil
file_telemetry_proto_goTypes = nil
file_telemetry_proto_depIdxs = nil
}
+52
View File
@@ -0,0 +1,52 @@
syntax = "proto3";
package telemetry;
option go_package = "github.com/seaweedfs/seaweedfs/telemetry/proto";
// TelemetryData represents cluster-level telemetry information
message TelemetryData {
// Unique cluster identifier (generated in-memory)
string cluster_id = 1;
// SeaweedFS version
string version = 2;
// Operating system (e.g., "linux/amd64")
string os = 3;
// Field 4 reserved (was features)
reserved 4;
// Field 5 reserved (was deployment)
reserved 5;
// Number of volume servers in the cluster
int32 volume_server_count = 6;
// Total disk usage across all volume servers (in bytes)
uint64 total_disk_bytes = 7;
// Total number of volumes in the cluster
int32 total_volume_count = 8;
// Number of filer servers in the cluster
int32 filer_count = 9;
// Number of broker servers in the cluster
int32 broker_count = 10;
// Unix timestamp when the data was collected
int64 timestamp = 11;
}
// TelemetryRequest is sent from SeaweedFS clusters to the telemetry server
message TelemetryRequest {
TelemetryData data = 1;
}
// TelemetryResponse is returned by the telemetry server
message TelemetryResponse {
bool success = 1;
string message = 2;
}
+18
View File
@@ -0,0 +1,18 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o telemetry-server .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/telemetry-server .
EXPOSE 8080
CMD ["./telemetry-server"]
+97
View File
@@ -0,0 +1,97 @@
.PHONY: build run clean test deps proto integration-test test-all
# Build the telemetry server
build:
go build -o telemetry-server .
# Run the server in development mode
run:
go run . -port=8080 -dashboard=true -cleanup=1h -max-age=24h
# Run the server in production mode
run-prod:
./telemetry-server -port=8080 -dashboard=true -cleanup=24h -max-age=720h
# Clean build artifacts
clean:
rm -f telemetry-server
rm -f ../test/telemetry-server-test.log
go clean
# Run unit tests
test:
go test ./...
# Run integration tests
integration-test:
@echo "🧪 Running telemetry integration tests..."
cd ../../ && go run telemetry/test/integration.go
# Run all tests (unit + integration)
test-all: test integration-test
# Install dependencies
deps:
go mod download
go mod tidy
# Generate protobuf code (requires protoc)
proto:
cd .. && protoc --go_out=. --go_opt=paths=source_relative proto/telemetry.proto
# Build Docker image
docker-build:
docker build -t seaweedfs-telemetry .
# Run with Docker
docker-run:
docker run -p 8080:8080 seaweedfs-telemetry -port=8080 -dashboard=true
# Development with auto-reload (requires air: go install github.com/cosmtrek/air@latest)
dev:
air
# Check if protoc is available
check-protoc:
@which protoc > /dev/null || (echo "protoc is required for proto generation. Install from https://grpc.io/docs/protoc-installation/" && exit 1)
# Full development setup
setup: check-protoc deps proto build
# Run a quick smoke test
smoke-test: build
@echo "🔥 Running smoke test..."
@timeout 10s ./telemetry-server -port=18081 > /dev/null 2>&1 & \
SERVER_PID=$$!; \
sleep 2; \
if curl -s http://localhost:18081/health > /dev/null; then \
echo "✅ Smoke test passed - server responds to health check"; \
else \
echo "❌ Smoke test failed - server not responding"; \
exit 1; \
fi; \
kill $$SERVER_PID 2>/dev/null || true
# Continuous integration target
ci: deps proto build test integration-test
@echo "🎉 All CI tests passed!"
# Help
help:
@echo "Available targets:"
@echo " build - Build the telemetry server binary"
@echo " run - Run server in development mode"
@echo " run-prod - Run server in production mode"
@echo " clean - Clean build artifacts"
@echo " test - Run unit tests"
@echo " integration-test- Run integration tests"
@echo " test-all - Run all tests (unit + integration)"
@echo " deps - Install Go dependencies"
@echo " proto - Generate protobuf code"
@echo " docker-build - Build Docker image"
@echo " docker-run - Run with Docker"
@echo " dev - Run with auto-reload (requires air)"
@echo " smoke-test - Quick server health check"
@echo " setup - Full development setup"
@echo " ci - Continuous integration (all tests)"
@echo " help - Show this help"
+152
View File
@@ -0,0 +1,152 @@
package api
import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
"github.com/seaweedfs/seaweedfs/telemetry/server/storage"
protobuf "google.golang.org/protobuf/proto"
)
type Handler struct {
storage *storage.PrometheusStorage
}
func NewHandler(storage *storage.PrometheusStorage) *Handler {
return &Handler{storage: storage}
}
func (h *Handler) CollectTelemetry(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
contentType := r.Header.Get("Content-Type")
// Only accept protobuf content type
if contentType != "application/x-protobuf" && contentType != "application/protobuf" {
http.Error(w, "Content-Type must be application/x-protobuf", http.StatusUnsupportedMediaType)
return
}
// Read protobuf request
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
req := &proto.TelemetryRequest{}
if err := protobuf.Unmarshal(body, req); err != nil {
http.Error(w, "Invalid protobuf data", http.StatusBadRequest)
return
}
data := req.Data
if data == nil {
http.Error(w, "Missing telemetry data", http.StatusBadRequest)
return
}
// Validate required fields
if data.ClusterId == "" || data.Version == "" || data.Os == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// Set timestamp if not provided
if data.Timestamp == 0 {
data.Timestamp = time.Now().Unix()
}
// Store the telemetry data
if err := h.storage.StoreTelemetry(data); err != nil {
http.Error(w, "Failed to store data", http.StatusInternalServerError)
return
}
// Return protobuf response
resp := &proto.TelemetryResponse{
Success: true,
Message: "Telemetry data received",
}
respData, err := protobuf.Marshal(resp)
if err != nil {
http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-protobuf")
w.WriteHeader(http.StatusOK)
w.Write(respData)
}
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
stats, err := h.storage.GetStats()
if err != nil {
http.Error(w, "Failed to get stats", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
func (h *Handler) GetInstances(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 100 // default
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
limit = l
}
}
instances, err := h.storage.GetInstances(limit)
if err != nil {
http.Error(w, "Failed to get instances", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(instances)
}
func (h *Handler) GetMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
daysStr := r.URL.Query().Get("days")
days := 30 // default
if daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 365 {
days = d
}
}
metrics, err := h.storage.GetMetrics(days)
if err != nil {
http.Error(w, "Failed to get metrics", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metrics)
}
+274
View File
@@ -0,0 +1,274 @@
package dashboard
import (
"net/http"
)
type Handler struct{}
func NewHandler() *Handler {
return &Handler{}
}
func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SeaweedFS Telemetry Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: #2196F3;
}
.stat-label {
color: #666;
margin-top: 5px;
}
.chart-container {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-title {
font-size: 1.2em;
font-weight: bold;
margin-bottom: 15px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 4px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>SeaweedFS Telemetry Dashboard</h1>
<p>Privacy-respecting usage analytics for SeaweedFS</p>
</div>
<div id="loading" class="loading">Loading telemetry data...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="dashboard" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="totalInstances">-</div>
<div class="stat-label">Total Instances (30 days)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="activeInstances">-</div>
<div class="stat-label">Active Instances (7 days)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalVersions">-</div>
<div class="stat-label">Different Versions</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalOS">-</div>
<div class="stat-label">Operating Systems</div>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Version Distribution</div>
<canvas id="versionChart" width="400" height="200"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">Operating System Distribution</div>
<canvas id="osChart" width="400" height="200"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">Volume Servers Over Time</div>
<canvas id="serverChart" width="400" height="200"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">Total Disk Usage Over Time</div>
<canvas id="diskChart" width="400" height="200"></canvas>
</div>
</div>
</div>
<script>
let charts = {};
async function loadDashboard() {
try {
// Load stats
const statsResponse = await fetch('/api/stats');
const stats = await statsResponse.json();
// Load metrics
const metricsResponse = await fetch('/api/metrics?days=30');
const metrics = await metricsResponse.json();
updateStats(stats);
updateCharts(stats, metrics);
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
} catch (error) {
console.error('Error loading dashboard:', error);
showError('Failed to load telemetry data: ' + error.message);
}
}
function updateStats(stats) {
document.getElementById('totalInstances').textContent = stats.total_instances || 0;
document.getElementById('activeInstances').textContent = stats.active_instances || 0;
document.getElementById('totalVersions').textContent = Object.keys(stats.versions || {}).length;
document.getElementById('totalOS').textContent = Object.keys(stats.os_distribution || {}).length;
}
function updateCharts(stats, metrics) {
// Version chart
createPieChart('versionChart', 'Version Distribution', stats.versions || {});
// OS chart
createPieChart('osChart', 'Operating System Distribution', stats.os_distribution || {});
// Server count over time
if (metrics.dates && metrics.server_counts) {
createLineChart('serverChart', 'Volume Servers', metrics.dates, metrics.server_counts, '#2196F3');
}
// Disk usage over time
if (metrics.dates && metrics.disk_usage) {
const diskUsageGB = metrics.disk_usage.map(bytes => Math.round(bytes / (1024 * 1024 * 1024)));
createLineChart('diskChart', 'Disk Usage (GB)', metrics.dates, diskUsageGB, '#4CAF50');
}
}
function createPieChart(canvasId, title, data) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (charts[canvasId]) {
charts[canvasId].destroy();
}
const labels = Object.keys(data);
const values = Object.values(data);
charts[canvasId] = new Chart(ctx, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
]
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
}
function createLineChart(canvasId, label, labels, data, color) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (charts[canvasId]) {
charts[canvasId].destroy();
}
charts[canvasId] = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: label,
data: data,
borderColor: color,
backgroundColor: color + '20',
fill: true,
tension: 0.1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
function showError(message) {
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = message;
}
// Load dashboard on page load
loadDashboard();
// Refresh every 5 minutes
setInterval(loadDashboard, 5 * 60 * 1000);
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(html))
}
+31
View File
@@ -0,0 +1,31 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/seaweedfs/seaweedfs/telemetry/server/api"
"github.com/seaweedfs/seaweedfs/telemetry/server/dashboard"
"github.com/seaweedfs/seaweedfs/telemetry/server/storage"
)
var (
port = flag.Int("port", 8080, "HTTP server port")
enableCORS = flag.Bool("cors", true, "Enable CORS for dashboard")
logRequests = flag.Bool("log", true, "Log incoming requests")
enableDashboard = flag.Bool("dashboard", true, "Enable built-in dashboard (optional when using Grafana)")
cleanupInterval = flag.Duration("cleanup", 24*time.Hour, "Cleanup interval for old instances")
maxInstanceAge = flag.Duration("max-age", 30*24*time.Hour, "Maximum age for instances before cleanup")
)
func main() {
flag.Parse()
// Create Prometheus storage instance
store := storage.NewPrometheusStorage()
// Start cleanup routine
go func() {
ticker := time.NewTicker(*cleanupInterval)
defer ticker.Stop()
for range ticker.C {
store.CleanupOldInstances(*maxInstanceAge)
}
}()
// Setup HTTP handlers
mux := http.NewServeMux()
// Prometheus metrics endpoint
mux.Handle("/metrics", promhttp.Handler())
// API endpoints
apiHandler := api.NewHandler(store)
mux.HandleFunc("/api/collect", corsMiddleware(logMiddleware(apiHandler.CollectTelemetry)))
mux.HandleFunc("/api/stats", corsMiddleware(logMiddleware(apiHandler.GetStats)))
mux.HandleFunc("/api/instances", corsMiddleware(logMiddleware(apiHandler.GetInstances)))
mux.HandleFunc("/api/metrics", corsMiddleware(logMiddleware(apiHandler.GetMetrics)))
// Dashboard (optional)
if *enableDashboard {
dashboardHandler := dashboard.NewHandler()
mux.HandleFunc("/", corsMiddleware(dashboardHandler.ServeIndex))
mux.HandleFunc("/dashboard", corsMiddleware(dashboardHandler.ServeIndex))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
}
// Health check
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
addr := fmt.Sprintf(":%d", *port)
log.Printf("Starting telemetry server on %s", addr)
log.Printf("Prometheus metrics: http://localhost%s/metrics", addr)
if *enableDashboard {
log.Printf("Dashboard: http://localhost%s/dashboard", addr)
}
log.Printf("Cleanup interval: %v, Max instance age: %v", *cleanupInterval, *maxInstanceAge)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if *enableCORS {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
func logMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if *logRequests {
start := time.Now()
next(w, r)
log.Printf("%s %s %s %v", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
} else {
next(w, r)
}
}
}
+235
View File
@@ -0,0 +1,235 @@
package storage
import (
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
type PrometheusStorage struct {
// Prometheus metrics
totalClusters prometheus.Gauge
activeClusters prometheus.Gauge
volumeServerCount *prometheus.GaugeVec
totalDiskBytes *prometheus.GaugeVec
totalVolumeCount *prometheus.GaugeVec
filerCount *prometheus.GaugeVec
brokerCount *prometheus.GaugeVec
clusterInfo *prometheus.GaugeVec
telemetryReceived prometheus.Counter
// In-memory storage for API endpoints (if needed)
mu sync.RWMutex
instances map[string]*telemetryData
stats map[string]interface{}
}
// telemetryData is an internal struct that includes the received timestamp
type telemetryData struct {
*proto.TelemetryData
ReceivedAt time.Time `json:"received_at"`
}
func NewPrometheusStorage() *PrometheusStorage {
return &PrometheusStorage{
totalClusters: promauto.NewGauge(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_total_clusters",
Help: "Total number of unique SeaweedFS clusters (last 30 days)",
}),
activeClusters: promauto.NewGauge(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_active_clusters",
Help: "Number of active SeaweedFS clusters (last 7 days)",
}),
volumeServerCount: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_volume_servers",
Help: "Number of volume servers per cluster",
}, []string{"cluster_id", "version", "os"}),
totalDiskBytes: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_disk_bytes",
Help: "Total disk usage in bytes per cluster",
}, []string{"cluster_id", "version", "os"}),
totalVolumeCount: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_volume_count",
Help: "Total number of volumes per cluster",
}, []string{"cluster_id", "version", "os"}),
filerCount: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_filer_count",
Help: "Number of filer servers per cluster",
}, []string{"cluster_id", "version", "os"}),
brokerCount: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_broker_count",
Help: "Number of broker servers per cluster",
}, []string{"cluster_id", "version", "os"}),
clusterInfo: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_cluster_info",
Help: "Cluster information (always 1, labels contain metadata)",
}, []string{"cluster_id", "version", "os"}),
telemetryReceived: promauto.NewCounter(prometheus.CounterOpts{
Name: "seaweedfs_telemetry_reports_received_total",
Help: "Total number of telemetry reports received",
}),
instances: make(map[string]*telemetryData),
stats: make(map[string]interface{}),
}
}
func (s *PrometheusStorage) StoreTelemetry(data *proto.TelemetryData) error {
s.mu.Lock()
defer s.mu.Unlock()
// Update Prometheus metrics
labels := prometheus.Labels{
"cluster_id": data.ClusterId,
"version": data.Version,
"os": data.Os,
}
s.volumeServerCount.With(labels).Set(float64(data.VolumeServerCount))
s.totalDiskBytes.With(labels).Set(float64(data.TotalDiskBytes))
s.totalVolumeCount.With(labels).Set(float64(data.TotalVolumeCount))
s.filerCount.With(labels).Set(float64(data.FilerCount))
s.brokerCount.With(labels).Set(float64(data.BrokerCount))
infoLabels := prometheus.Labels{
"cluster_id": data.ClusterId,
"version": data.Version,
"os": data.Os,
}
s.clusterInfo.With(infoLabels).Set(1)
s.telemetryReceived.Inc()
// Store in memory for API endpoints
s.instances[data.ClusterId] = &telemetryData{
TelemetryData: data,
ReceivedAt: time.Now().UTC(),
}
// Update aggregated stats
s.updateStats()
return nil
}
func (s *PrometheusStorage) GetStats() (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Return cached stats
result := make(map[string]interface{})
for k, v := range s.stats {
result[k] = v
}
return result, nil
}
func (s *PrometheusStorage) GetInstances(limit int) ([]*telemetryData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var instances []*telemetryData
count := 0
for _, instance := range s.instances {
if count >= limit {
break
}
instances = append(instances, instance)
count++
}
return instances, nil
}
func (s *PrometheusStorage) GetMetrics(days int) (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Return current metrics from in-memory storage
// Historical data should be queried from Prometheus directly
cutoff := time.Now().AddDate(0, 0, -days)
var volumeServers []map[string]interface{}
var diskUsage []map[string]interface{}
for _, instance := range s.instances {
if instance.ReceivedAt.After(cutoff) {
volumeServers = append(volumeServers, map[string]interface{}{
"date": instance.ReceivedAt.Format("2006-01-02"),
"value": instance.TelemetryData.VolumeServerCount,
})
diskUsage = append(diskUsage, map[string]interface{}{
"date": instance.ReceivedAt.Format("2006-01-02"),
"value": instance.TelemetryData.TotalDiskBytes,
})
}
}
return map[string]interface{}{
"volume_servers": volumeServers,
"disk_usage": diskUsage,
}, nil
}
func (s *PrometheusStorage) updateStats() {
now := time.Now()
last7Days := now.AddDate(0, 0, -7)
last30Days := now.AddDate(0, 0, -30)
totalInstances := 0
activeInstances := 0
versions := make(map[string]int)
osDistribution := make(map[string]int)
for _, instance := range s.instances {
if instance.ReceivedAt.After(last30Days) {
totalInstances++
}
if instance.ReceivedAt.After(last7Days) {
activeInstances++
versions[instance.TelemetryData.Version]++
osDistribution[instance.TelemetryData.Os]++
}
}
// Update Prometheus gauges
s.totalClusters.Set(float64(totalInstances))
s.activeClusters.Set(float64(activeInstances))
// Update cached stats for API
s.stats = map[string]interface{}{
"total_instances": totalInstances,
"active_instances": activeInstances,
"versions": versions,
"os_distribution": osDistribution,
}
}
// CleanupOldInstances removes instances older than the specified duration
func (s *PrometheusStorage) CleanupOldInstances(maxAge time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-maxAge)
for instanceID, instance := range s.instances {
if instance.ReceivedAt.Before(cutoff) {
delete(s.instances, instanceID)
// Remove from Prometheus metrics
labels := prometheus.Labels{
"cluster_id": instance.TelemetryData.ClusterId,
"version": instance.TelemetryData.Version,
"os": instance.TelemetryData.Os,
}
s.volumeServerCount.Delete(labels)
s.totalDiskBytes.Delete(labels)
s.totalVolumeCount.Delete(labels)
s.filerCount.Delete(labels)
s.brokerCount.Delete(labels)
}
}
s.updateStats()
}
+311
View File
@@ -0,0 +1,311 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
"github.com/seaweedfs/seaweedfs/weed/telemetry"
protobuf "google.golang.org/protobuf/proto"
)
const (
serverPort = "18080" // Use different port to avoid conflicts
serverURL = "http://localhost:" + serverPort
)
func main() {
fmt.Println("🧪 Starting SeaweedFS Telemetry Integration Test")
// Start telemetry server
fmt.Println("📡 Starting telemetry server...")
serverCmd, err := startTelemetryServer()
if err != nil {
log.Fatalf("❌ Failed to start telemetry server: %v", err)
}
defer stopServer(serverCmd)
// Wait for server to start
if !waitForServer(serverURL+"/health", 15*time.Second) {
log.Fatal("❌ Telemetry server failed to start")
}
fmt.Println("✅ Telemetry server started successfully")
// Test protobuf marshaling first
fmt.Println("🔧 Testing protobuf marshaling...")
if err := testProtobufMarshaling(); err != nil {
log.Fatalf("❌ Protobuf marshaling test failed: %v", err)
}
fmt.Println("✅ Protobuf marshaling test passed")
// Test protobuf client
fmt.Println("🔄 Testing protobuf telemetry client...")
if err := testTelemetryClient(); err != nil {
log.Fatalf("❌ Telemetry client test failed: %v", err)
}
fmt.Println("✅ Telemetry client test passed")
// Test server metrics endpoint
fmt.Println("📊 Testing Prometheus metrics endpoint...")
if err := testMetricsEndpoint(); err != nil {
log.Fatalf("❌ Metrics endpoint test failed: %v", err)
}
fmt.Println("✅ Metrics endpoint test passed")
// Test stats API
fmt.Println("📈 Testing stats API...")
if err := testStatsAPI(); err != nil {
log.Fatalf("❌ Stats API test failed: %v", err)
}
fmt.Println("✅ Stats API test passed")
// Test instances API
fmt.Println("📋 Testing instances API...")
if err := testInstancesAPI(); err != nil {
log.Fatalf("❌ Instances API test failed: %v", err)
}
fmt.Println("✅ Instances API test passed")
fmt.Println("🎉 All telemetry integration tests passed!")
}
func startTelemetryServer() (*exec.Cmd, error) {
// Get the directory where this test is running
testDir, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get working directory: %v", err)
}
// Navigate to the server directory (from main seaweedfs directory)
serverDir := filepath.Join(testDir, "telemetry", "server")
cmd := exec.Command("go", "run", ".",
"-port="+serverPort,
"-dashboard=false",
"-cleanup=1m",
"-max-age=1h")
cmd.Dir = serverDir
// Create log files for server output
logFile, err := os.Create("telemetry-server-test.log")
if err != nil {
return nil, fmt.Errorf("failed to create log file: %v", err)
}
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start server: %v", err)
}
return cmd, nil
}
func stopServer(cmd *exec.Cmd) {
if cmd != nil && cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
cmd.Wait()
// Clean up log file
os.Remove("telemetry-server-test.log")
}
}
func waitForServer(url string, timeout time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fmt.Printf("⏳ Waiting for server at %s...\n", url)
for {
select {
case <-ctx.Done():
return false
default:
resp, err := http.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return true
}
}
time.Sleep(500 * time.Millisecond)
}
}
}
func testProtobufMarshaling() error {
// Test protobuf marshaling/unmarshaling
testData := &proto.TelemetryData{
ClusterId: "test-cluster-12345",
Version: "test-3.45",
Os: "linux/amd64",
VolumeServerCount: 2,
TotalDiskBytes: 1000000,
TotalVolumeCount: 10,
FilerCount: 1,
BrokerCount: 1,
Timestamp: time.Now().Unix(),
}
// Marshal
data, err := protobuf.Marshal(testData)
if err != nil {
return fmt.Errorf("failed to marshal protobuf: %v", err)
}
fmt.Printf(" Protobuf size: %d bytes\n", len(data))
// Unmarshal
testData2 := &proto.TelemetryData{}
if err := protobuf.Unmarshal(data, testData2); err != nil {
return fmt.Errorf("failed to unmarshal protobuf: %v", err)
}
// Verify data
if testData2.ClusterId != testData.ClusterId {
return fmt.Errorf("protobuf data mismatch: expected %s, got %s",
testData.ClusterId, testData2.ClusterId)
}
if testData2.VolumeServerCount != testData.VolumeServerCount {
return fmt.Errorf("volume server count mismatch: expected %d, got %d",
testData.VolumeServerCount, testData2.VolumeServerCount)
}
return nil
}
func testTelemetryClient() error {
// Create telemetry client
client := telemetry.NewClient(serverURL+"/api/collect", true)
// Create test data using protobuf format
testData := &proto.TelemetryData{
Version: "test-3.45",
Os: "linux/amd64",
VolumeServerCount: 3,
TotalDiskBytes: 1073741824, // 1GB
TotalVolumeCount: 50,
FilerCount: 2,
BrokerCount: 1,
Timestamp: time.Now().Unix(),
}
// Send telemetry data
if err := client.SendTelemetry(testData); err != nil {
return fmt.Errorf("failed to send telemetry: %v", err)
}
fmt.Printf(" Sent telemetry for cluster: %s\n", client.GetInstanceID())
// Wait a bit for processing
time.Sleep(2 * time.Second)
return nil
}
func testMetricsEndpoint() error {
resp, err := http.Get(serverURL + "/metrics")
if err != nil {
return fmt.Errorf("failed to get metrics: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("metrics endpoint returned status %d", resp.StatusCode)
}
// Read response and check for expected metrics
content, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read metrics response: %v", err)
}
contentStr := string(content)
expectedMetrics := []string{
"seaweedfs_telemetry_total_clusters",
"seaweedfs_telemetry_active_clusters",
"seaweedfs_telemetry_reports_received_total",
"seaweedfs_telemetry_volume_servers",
"seaweedfs_telemetry_disk_bytes",
"seaweedfs_telemetry_volume_count",
"seaweedfs_telemetry_filer_count",
"seaweedfs_telemetry_broker_count",
}
for _, metric := range expectedMetrics {
if !strings.Contains(contentStr, metric) {
return fmt.Errorf("missing expected metric: %s", metric)
}
}
// Check that we have at least one report received
if !strings.Contains(contentStr, "seaweedfs_telemetry_reports_received_total 1") {
fmt.Printf(" Warning: Expected at least 1 report received, metrics content:\n%s\n", contentStr)
}
fmt.Printf(" Found %d expected metrics\n", len(expectedMetrics))
return nil
}
func testStatsAPI() error {
resp, err := http.Get(serverURL + "/api/stats")
if err != nil {
return fmt.Errorf("failed to get stats: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("stats API returned status %d", resp.StatusCode)
}
// Read and verify JSON response
content, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read stats response: %v", err)
}
contentStr := string(content)
if !strings.Contains(contentStr, "total_instances") {
return fmt.Errorf("stats response missing total_instances field")
}
fmt.Printf(" Stats response: %s\n", contentStr)
return nil
}
func testInstancesAPI() error {
resp, err := http.Get(serverURL + "/api/instances?limit=10")
if err != nil {
return fmt.Errorf("failed to get instances: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("instances API returned status %d", resp.StatusCode)
}
// Read response
content, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read instances response: %v", err)
}
fmt.Printf(" Instances response length: %d bytes\n", len(content))
return nil
}
+165
View File
@@ -0,0 +1,165 @@
# SeaweedFS Admin Component Makefile
# Variables
ADMIN_DIR := .
VIEW_DIR := $(ADMIN_DIR)/view
STATIC_DIR := $(ADMIN_DIR)/static
TEMPL_FILES := $(shell find $(VIEW_DIR) -name "*.templ")
TEMPL_GO_FILES := $(TEMPL_FILES:.templ=_templ.go)
GO_FILES := $(shell find $(ADMIN_DIR) -name "*.go" -not -name "*_templ.go")
BUILD_DIR := ../..
WEED_BINARY := $(BUILD_DIR)/weed
# Default target
.PHONY: all
all: build
# Install templ if not present
.PHONY: install-templ
install-templ:
@which templ > /dev/null || (echo "Installing templ..." && go install github.com/a-h/templ/cmd/templ@latest)
# Generate templ files
.PHONY: generate
generate: install-templ
@echo "Generating templ files..."
@templ generate
@echo "Generated: $(TEMPL_GO_FILES)"
# Clean generated files
.PHONY: clean-templ
clean-templ:
@echo "Cleaning generated templ files..."
@find $(VIEW_DIR) -name "*_templ.go" -delete
@echo "Cleaned templ files"
# Watch for changes and regenerate
.PHONY: watch
watch: install-templ
@echo "Watching for templ file changes..."
@templ generate --watch
# Build the main weed binary with admin component
.PHONY: build
build: generate
@echo "Building weed binary with admin component..."
@cd $(BUILD_DIR) && go build -o weed ./weed
@echo "Built: $(BUILD_DIR)/weed"
# Test the admin component
.PHONY: test
test: generate
@echo "Running admin component tests..."
@go test ./...
# Run the admin server via weed command
.PHONY: run
run: build
@echo "Starting admin server via weed command..."
@cd $(BUILD_DIR) && ./weed admin
# Development server with auto-reload
.PHONY: dev
dev: generate
@echo "Starting development server with auto-reload..."
@echo "Note: You'll need to manually restart the server when Go files change"
@cd $(BUILD_DIR) && ./weed admin -port=23647 &
@$(MAKE) watch
# Lint the code
.PHONY: lint
lint:
@echo "Linting admin component..."
@golangci-lint run ./...
# Format the code
.PHONY: fmt
fmt:
@echo "Formatting Go code..."
@go fmt ./...
@echo "Formatting templ files..."
@templ fmt $(VIEW_DIR)
# Validate static files exist
.PHONY: validate-static
validate-static:
@echo "Validating static files..."
@test -f $(STATIC_DIR)/css/admin.css || (echo "Missing: admin.css" && exit 1)
@test -f $(STATIC_DIR)/js/admin.js || (echo "Missing: admin.js" && exit 1)
@echo "Static files validated"
# Check dependencies
.PHONY: deps
deps:
@echo "Checking dependencies..."
@go mod tidy
@go mod verify
# Clean all build artifacts
.PHONY: clean
clean: clean-templ
@echo "Cleaning build artifacts..."
@rm -f $(BUILD_DIR)/weed 2>/dev/null || true
@echo "Cleaned build artifacts"
# Install dependencies
.PHONY: install-deps
install-deps:
@echo "Installing Go dependencies..."
@go mod download
@$(MAKE) install-templ
# Production build
.PHONY: build-prod
build-prod: clean generate validate-static
@echo "Building production binary..."
@cd $(BUILD_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o weed-linux-amd64 ./weed
@echo "Built production binary: $(BUILD_DIR)/weed-linux-amd64"
# Docker build (if needed)
.PHONY: docker-build
docker-build: generate
@echo "Building Docker image with admin component..."
@cd $(BUILD_DIR) && docker build -t seaweedfs/seaweedfs:latest .
# Help target
.PHONY: help
help:
@echo "SeaweedFS Admin Component Makefile"
@echo ""
@echo "Available targets:"
@echo " all - Build the weed binary with admin component (default)"
@echo " generate - Generate templ files from templates"
@echo " build - Build weed binary with admin component"
@echo " build-prod - Build production binary"
@echo " run - Run admin server via weed command"
@echo " dev - Start development server with template watching"
@echo " test - Run tests"
@echo " watch - Watch for template changes and regenerate"
@echo " clean - Clean all build artifacts"
@echo " clean-templ - Clean generated template files"
@echo " fmt - Format Go and templ code"
@echo " lint - Lint the code"
@echo " deps - Check and tidy dependencies"
@echo " install-deps - Install all dependencies"
@echo " install-templ - Install templ compiler"
@echo " validate-static - Validate static files exist"
@echo " docker-build - Build Docker image"
@echo " help - Show this help message"
@echo ""
@echo "Examples:"
@echo " make generate # Generate templates"
@echo " make build # Build weed binary"
@echo " make run # Start admin server"
@echo " make dev # Development mode with auto-reload"
# Make sure generated files are up to date before building
$(WEED_BINARY): $(TEMPL_GO_FILES) $(GO_FILES)
@$(MAKE) build
# Auto-generate templ files when .templ files change
%_templ.go: %.templ
@echo "Regenerating $@ from $<"
@templ generate
.PHONY: $(TEMPL_GO_FILES)
+279
View File
@@ -0,0 +1,279 @@
# SeaweedFS Admin Component
A modern web-based administration interface for SeaweedFS clusters built with Go, Gin, Templ, and Bootstrap.
## Features
- **Dashboard**: Real-time cluster status and metrics
- **Master Management**: Monitor master nodes and leadership status
- **Volume Server Management**: View volume servers, capacity, and health
- **Object Store Bucket Management**: Create, delete, and manage Object Store buckets with web interface
- **System Health**: Overall cluster health monitoring
- **Responsive Design**: Bootstrap-based UI that works on all devices
- **Authentication**: Optional user authentication with sessions
- **TLS Support**: HTTPS support for production deployments
## Building
### Using the Admin Makefile
The admin component has its own Makefile for development and building:
```bash
# Navigate to admin directory
cd weed/admin
# View all available targets
make help
# Generate templates and build
make build
# Development mode with template watching
make dev
# Run the admin server
make run
# Clean build artifacts
make clean
```
### Using the Root Makefile
The root SeaweedFS Makefile automatically integrates the admin component:
```bash
# From the root directory
make install # Builds weed with admin component
make full_install # Full build with all tags
make test # Runs tests including admin component
# Admin-specific targets from root
make admin-generate # Generate admin templates
make admin-build # Build admin component
make admin-run # Run admin server
make admin-dev # Development mode
make admin-clean # Clean admin artifacts
```
### Manual Building
If you prefer to build manually:
```bash
# Install templ compiler
go install github.com/a-h/templ/cmd/templ@latest
# Generate templates
templ generate
# Build the main weed binary
cd ../../../
go build -o weed ./weed
```
## Development
### Template Development
The admin interface uses [Templ](https://templ.guide/) for type-safe HTML templates:
```bash
# Watch for template changes and auto-regenerate
make watch
# Or manually generate templates
make generate
# Format templates
make fmt
```
### File Structure
```
weed/admin/
├── Makefile # Admin-specific build tasks
├── README.md # This file
├── S3_BUCKETS.md # Object Store bucket management documentation
├── admin.go # Main application entry point
├── dash/ # Server and handler logic
│ ├── admin_server.go # HTTP server setup
│ ├── handler_admin.go # Admin dashboard handlers
│ ├── handler_auth.go # Authentication handlers
│ └── middleware.go # HTTP middleware
├── static/ # Static assets
│ ├── css/admin.css # Admin-specific styles
│ └── js/admin.js # Admin-specific JavaScript
└── view/ # Templates
├── app/ # Application templates
│ ├── admin.templ # Main dashboard template
│ ├── s3_buckets.templ # Object Store bucket management template
│ └── *_templ.go # Generated Go code
└── layout/ # Layout templates
├── layout.templ # Base layout template
└── layout_templ.go # Generated Go code
```
### S3 Bucket Management
The admin interface includes comprehensive Object Store bucket management capabilities. See [S3_BUCKETS.md](S3_BUCKETS.md) for detailed documentation on:
- Creating and deleting Object Store buckets
- Viewing bucket contents and metadata
- Managing bucket permissions and settings
- API endpoints for programmatic access
## Usage
### Basic Usage
```bash
# Start admin interface on default port (23646)
weed admin
# Start with custom configuration
weed admin -port=8080 -masters="master1:9333,master2:9333"
# Start with authentication
weed admin -adminUser=admin -adminPassword=secret123
# Start with HTTPS
weed admin -port=443 -tlsCert=/path/to/cert.pem -tlsKey=/path/to/key.pem
```
### Configuration Options
| Option | Default | Description |
|--------|---------|-------------|
| `-port` | 23646 | Admin server port |
| `-masters` | localhost:9333 | Comma-separated master servers |
| `-adminUser` | admin | Admin username (if auth enabled) |
| `-adminPassword` | "" | Admin password (empty = no auth) |
| `-tlsCert` | "" | Path to TLS certificate |
| `-tlsKey` | "" | Path to TLS private key |
### Docker Usage
```bash
# Build Docker image with admin component
make docker-build
# Run with Docker
docker run -p 23646:23646 seaweedfs/seaweedfs:latest admin -masters=host.docker.internal:9333
```
## Development Workflow
### Quick Start
```bash
# Clone and setup
git clone <seaweedfs-repo>
cd seaweedfs/weed/admin
# Install dependencies and build
make install-deps
make build
# Start development server
make dev
```
### Making Changes
1. **Template Changes**: Edit `.templ` files in `view/`
- Templates auto-regenerate in development mode
- Use `make generate` to manually regenerate
2. **Go Code Changes**: Edit `.go` files
- Restart the server to see changes
- Use `make build` to rebuild
3. **Static Assets**: Edit files in `static/`
- Changes are served immediately
### Testing
```bash
# Run admin component tests
make test
# Run from root directory
make admin-test
# Lint code
make lint
# Format code
make fmt
```
## Production Deployment
### Security Considerations
1. **Authentication**: Always set `adminPassword` in production
2. **HTTPS**: Use TLS certificates for encrypted connections
3. **Firewall**: Restrict admin interface access to authorized networks
### Example Production Setup
```bash
# Production deployment with security
weed admin \
-port=443 \
-masters="master1:9333,master2:9333,master3:9333" \
-adminUser=admin \
-adminPassword=your-secure-password \
-tlsCert=/etc/ssl/certs/admin.crt \
-tlsKey=/etc/ssl/private/admin.key
```
### Monitoring
The admin interface provides endpoints for monitoring:
- `GET /health` - Health check endpoint
- `GET /metrics` - Prometheus metrics (if enabled)
- `GET /api/status` - JSON status information
## Troubleshooting
### Common Issues
1. **Templates not found**: Run `make generate` to create template files
2. **Build errors**: Ensure `templ` is installed with `make install-templ`
3. **Static files not loading**: Check that `static/` directory exists and has proper files
4. **Connection errors**: Verify master and filer addresses are correct
### Debug Mode
```bash
# Enable debug logging
weed -v=2 admin
# Check generated templates
ls -la view/app/*_templ.go view/layout/*_templ.go
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests: `make test`
5. Format code: `make fmt`
6. Submit a pull request
## Architecture
The admin component follows a clean architecture:
- **Presentation Layer**: Templ templates + Bootstrap CSS
- **HTTP Layer**: Gin router with middleware
- **Business Logic**: Handler functions in `dash/` package
- **Data Layer**: Communicates with SeaweedFS masters and filers
This separation makes the code maintainable and testable.
+202
View File
@@ -0,0 +1,202 @@
package dash
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
type AdminData struct {
Username string `json:"username"`
TotalVolumes int `json:"total_volumes"`
TotalFiles int64 `json:"total_files"`
TotalSize int64 `json:"total_size"`
MasterNodes []MasterNode `json:"master_nodes"`
VolumeServers []VolumeServer `json:"volume_servers"`
FilerNodes []FilerNode `json:"filer_nodes"`
DataCenters []DataCenter `json:"datacenters"`
LastUpdated time.Time `json:"last_updated"`
}
// Object Store Users management structures
type ObjectStoreUser struct {
Username string `json:"username"`
Email string `json:"email"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Permissions []string `json:"permissions"`
}
type ObjectStoreUsersData struct {
Username string `json:"username"`
Users []ObjectStoreUser `json:"users"`
TotalUsers int `json:"total_users"`
LastUpdated time.Time `json:"last_updated"`
}
// User management request structures
type CreateUserRequest struct {
Username string `json:"username" binding:"required"`
Email string `json:"email"`
Actions []string `json:"actions"`
GenerateKey bool `json:"generate_key"`
}
type UpdateUserRequest struct {
Email string `json:"email"`
Actions []string `json:"actions"`
}
type UpdateUserPoliciesRequest struct {
Actions []string `json:"actions" binding:"required"`
}
type AccessKeyInfo struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
CreatedAt time.Time `json:"created_at"`
}
type UserDetails struct {
Username string `json:"username"`
Email string `json:"email"`
Actions []string `json:"actions"`
AccessKeys []AccessKeyInfo `json:"access_keys"`
}
type FilerNode struct {
Address string `json:"address"`
DataCenter string `json:"datacenter"`
Rack string `json:"rack"`
LastUpdated time.Time `json:"last_updated"`
}
// GetAdminData retrieves admin data as a struct (for reuse by both JSON and HTML handlers)
func (s *AdminServer) GetAdminData(username string) (AdminData, error) {
if username == "" {
username = "admin"
}
// Get cluster topology
topology, err := s.GetClusterTopology()
if err != nil {
glog.Errorf("Failed to get cluster topology: %v", err)
return AdminData{}, err
}
// Get master nodes status
masterNodes := s.getMasterNodesStatus()
// Get filer nodes status
filerNodes := s.getFilerNodesStatus()
// Prepare admin data
adminData := AdminData{
Username: username,
TotalVolumes: topology.TotalVolumes,
TotalFiles: topology.TotalFiles,
TotalSize: topology.TotalSize,
MasterNodes: masterNodes,
VolumeServers: topology.VolumeServers,
FilerNodes: filerNodes,
DataCenters: topology.DataCenters,
LastUpdated: topology.UpdatedAt,
}
return adminData, nil
}
// ShowAdmin displays the main admin page (now uses GetAdminData)
func (s *AdminServer) ShowAdmin(c *gin.Context) {
username := c.GetString("username")
adminData, err := s.GetAdminData(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get admin data: " + err.Error()})
return
}
// Return JSON for API calls
c.JSON(http.StatusOK, adminData)
}
// ShowOverview displays cluster overview
func (s *AdminServer) ShowOverview(c *gin.Context) {
topology, err := s.GetClusterTopology()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, topology)
}
// getMasterNodesStatus checks status of all master nodes
func (s *AdminServer) getMasterNodesStatus() []MasterNode {
var masterNodes []MasterNode
// Since we have a single master address, create one entry
var isLeader bool = true // Assume leader since it's the only master we know about
// Try to get leader info from this master
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
_, err := client.GetMasterConfiguration(context.Background(), &master_pb.GetMasterConfigurationRequest{})
if err != nil {
return err
}
// For now, assume this master is the leader since we can connect to it
isLeader = true
return nil
})
if err != nil {
isLeader = false
}
masterNodes = append(masterNodes, MasterNode{
Address: s.masterAddress,
IsLeader: isLeader,
})
return masterNodes
}
// getFilerNodesStatus checks status of all filer nodes using master's ListClusterNodes
func (s *AdminServer) getFilerNodesStatus() []FilerNode {
var filerNodes []FilerNode
// Get filer nodes from master using ListClusterNodes
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.ListClusterNodes(context.Background(), &master_pb.ListClusterNodesRequest{
ClientType: cluster.FilerType,
})
if err != nil {
return err
}
// Process each filer node
for _, node := range resp.ClusterNodes {
filerNodes = append(filerNodes, FilerNode{
Address: node.Address,
DataCenter: node.DataCenter,
Rack: node.Rack,
LastUpdated: time.Now(),
})
}
return nil
})
if err != nil {
glog.Errorf("Failed to get filer nodes from master %s: %v", s.masterAddress, err)
// Return empty list if we can't get filer info from master
return []FilerNode{}
}
return filerNodes
}
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
package dash
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// ShowLogin displays the login page
func (s *AdminServer) ShowLogin(c *gin.Context) {
// If authentication is not required, redirect to admin
session := sessions.Default(c)
if session.Get("authenticated") == true {
c.Redirect(http.StatusSeeOther, "/admin")
return
}
// For now, return a simple login form as JSON
c.HTML(http.StatusOK, "login.html", gin.H{
"title": "SeaweedFS Admin Login",
"error": c.Query("error"),
})
}
// HandleLogin handles login form submission
func (s *AdminServer) HandleLogin(username, password string) gin.HandlerFunc {
return func(c *gin.Context) {
loginUsername := c.PostForm("username")
loginPassword := c.PostForm("password")
if loginUsername == username && loginPassword == password {
session := sessions.Default(c)
session.Set("authenticated", true)
session.Set("username", loginUsername)
session.Save()
c.Redirect(http.StatusSeeOther, "/admin")
return
}
// Authentication failed
c.Redirect(http.StatusSeeOther, "/login?error=Invalid credentials")
}
}
// HandleLogout handles user logout
func (s *AdminServer) HandleLogout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Save()
c.Redirect(http.StatusSeeOther, "/login")
}
+325
View File
@@ -0,0 +1,325 @@
package dash
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// S3 Bucket management data structures for templates
type S3BucketsData struct {
Username string `json:"username"`
Buckets []S3Bucket `json:"buckets"`
TotalBuckets int `json:"total_buckets"`
TotalSize int64 `json:"total_size"`
LastUpdated time.Time `json:"last_updated"`
}
type CreateBucketRequest struct {
Name string `json:"name" binding:"required"`
Region string `json:"region"`
QuotaSize int64 `json:"quota_size"` // Quota size in bytes
QuotaUnit string `json:"quota_unit"` // Unit: MB, GB, TB
QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled
}
// S3 Bucket Management Handlers
// ShowS3Buckets displays the Object Store buckets management page
func (s *AdminServer) ShowS3Buckets(c *gin.Context) {
username := c.GetString("username")
buckets, err := s.GetS3Buckets()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get Object Store buckets: " + err.Error()})
return
}
// Calculate totals
var totalSize int64
for _, bucket := range buckets {
totalSize += bucket.Size
}
data := S3BucketsData{
Username: username,
Buckets: buckets,
TotalBuckets: len(buckets),
TotalSize: totalSize,
LastUpdated: time.Now(),
}
c.JSON(http.StatusOK, data)
}
// ShowBucketDetails displays detailed information about a specific bucket
func (s *AdminServer) ShowBucketDetails(c *gin.Context) {
bucketName := c.Param("bucket")
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
return
}
details, err := s.GetBucketDetails(bucketName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()})
return
}
c.JSON(http.StatusOK, details)
}
// CreateBucket creates a new S3 bucket
func (s *AdminServer) CreateBucket(c *gin.Context) {
var req CreateBucketRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Validate bucket name (basic validation)
if len(req.Name) < 3 || len(req.Name) > 63 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name must be between 3 and 63 characters"})
return
}
// Convert quota to bytes
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
err := s.CreateS3BucketWithQuota(req.Name, quotaBytes, req.QuotaEnabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bucket: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Bucket created successfully",
"bucket": req.Name,
"quota_size": req.QuotaSize,
"quota_unit": req.QuotaUnit,
"quota_enabled": req.QuotaEnabled,
})
}
// UpdateBucketQuota updates the quota settings for a bucket
func (s *AdminServer) UpdateBucketQuota(c *gin.Context) {
bucketName := c.Param("bucket")
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
return
}
var req struct {
QuotaSize int64 `json:"quota_size"`
QuotaUnit string `json:"quota_unit"`
QuotaEnabled bool `json:"quota_enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Convert quota to bytes
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
err := s.SetBucketQuota(bucketName, quotaBytes, req.QuotaEnabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket quota: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Bucket quota updated successfully",
"bucket": bucketName,
"quota_size": req.QuotaSize,
"quota_unit": req.QuotaUnit,
"quota_enabled": req.QuotaEnabled,
})
}
// DeleteBucket deletes an S3 bucket
func (s *AdminServer) DeleteBucket(c *gin.Context) {
bucketName := c.Param("bucket")
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
return
}
err := s.DeleteS3Bucket(bucketName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete bucket: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Bucket deleted successfully",
"bucket": bucketName,
})
}
// ListBucketsAPI returns the list of buckets as JSON
func (s *AdminServer) ListBucketsAPI(c *gin.Context) {
buckets, err := s.GetS3Buckets()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get buckets: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"buckets": buckets,
"total": len(buckets),
})
}
// Helper function to convert quota size and unit to bytes
func convertQuotaToBytes(size int64, unit string) int64 {
if size <= 0 {
return 0
}
switch strings.ToUpper(unit) {
case "TB":
return size * 1024 * 1024 * 1024 * 1024
case "GB":
return size * 1024 * 1024 * 1024
case "MB":
return size * 1024 * 1024
default:
// Default to MB if unit is not recognized
return size * 1024 * 1024
}
}
// Helper function to convert bytes to appropriate unit and size
func convertBytesToQuota(bytes int64) (int64, string) {
if bytes == 0 {
return 0, "MB"
}
// Convert to TB if >= 1TB
if bytes >= 1024*1024*1024*1024 && bytes%(1024*1024*1024*1024) == 0 {
return bytes / (1024 * 1024 * 1024 * 1024), "TB"
}
// Convert to GB if >= 1GB
if bytes >= 1024*1024*1024 && bytes%(1024*1024*1024) == 0 {
return bytes / (1024 * 1024 * 1024), "GB"
}
// Convert to MB (default)
return bytes / (1024 * 1024), "MB"
}
// SetBucketQuota sets the quota for a bucket
func (s *AdminServer) SetBucketQuota(bucketName string, quotaBytes int64, quotaEnabled bool) error {
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
// Get the current bucket entry
lookupResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
Directory: "/buckets",
Name: bucketName,
})
if err != nil {
return fmt.Errorf("bucket not found: %v", err)
}
bucketEntry := lookupResp.Entry
// Determine quota value (negative if disabled)
var quota int64
if quotaEnabled && quotaBytes > 0 {
quota = quotaBytes
} else if !quotaEnabled && quotaBytes > 0 {
quota = -quotaBytes
} else {
quota = 0
}
// Update the quota
bucketEntry.Quota = quota
// Update the entry
_, err = client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
Directory: "/buckets",
Entry: bucketEntry,
})
if err != nil {
return fmt.Errorf("failed to update bucket quota: %v", err)
}
return nil
})
}
// CreateS3BucketWithQuota creates a new S3 bucket with quota settings
func (s *AdminServer) CreateS3BucketWithQuota(bucketName string, quotaBytes int64, quotaEnabled bool) error {
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
// First ensure /buckets directory exists
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
Directory: "/",
Entry: &filer_pb.Entry{
Name: "buckets",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
FileMode: uint32(0755 | os.ModeDir), // Directory mode
Uid: uint32(1000),
Gid: uint32(1000),
Crtime: time.Now().Unix(),
Mtime: time.Now().Unix(),
TtlSec: 0,
},
},
})
// Ignore error if directory already exists
if err != nil && !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "existing entry") {
return fmt.Errorf("failed to create /buckets directory: %v", err)
}
// Check if bucket already exists
_, err = client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
Directory: "/buckets",
Name: bucketName,
})
if err == nil {
return fmt.Errorf("bucket %s already exists", bucketName)
}
// Determine quota value (negative if disabled)
var quota int64
if quotaEnabled && quotaBytes > 0 {
quota = quotaBytes
} else if !quotaEnabled && quotaBytes > 0 {
quota = -quotaBytes
} else {
quota = 0
}
// Create bucket directory under /buckets
_, err = client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
Directory: "/buckets",
Entry: &filer_pb.Entry{
Name: bucketName,
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
FileMode: uint32(0755 | os.ModeDir), // Directory mode
Uid: filer_pb.OS_UID,
Gid: filer_pb.OS_GID,
Crtime: time.Now().Unix(),
Mtime: time.Now().Unix(),
TtlSec: 0,
},
Quota: quota,
},
})
if err != nil {
return fmt.Errorf("failed to create bucket directory: %v", err)
}
return nil
})
}
+96
View File
@@ -0,0 +1,96 @@
package dash
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
// WithMasterClient executes a function with a master client connection
func (s *AdminServer) WithMasterClient(f func(client master_pb.SeaweedClient) error) error {
masterAddr := pb.ServerAddress(s.masterAddress)
return pb.WithMasterClient(false, masterAddr, s.grpcDialOption, false, func(client master_pb.SeaweedClient) error {
return f(client)
})
}
// WithFilerClient executes a function with a filer client connection
func (s *AdminServer) WithFilerClient(f func(client filer_pb.SeaweedFilerClient) error) error {
filerAddr := s.GetFilerAddress()
if filerAddr == "" {
return fmt.Errorf("no filer available")
}
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddr), s.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
return f(client)
})
}
// WithVolumeServerClient executes a function with a volume server client connection
func (s *AdminServer) WithVolumeServerClient(address pb.ServerAddress, f func(client volume_server_pb.VolumeServerClient) error) error {
return operation.WithVolumeServerClient(false, address, s.grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
return f(client)
})
}
// GetFilerAddress returns a filer address, discovering from masters if needed
func (s *AdminServer) GetFilerAddress() string {
// Discover filers from masters
filers := s.getDiscoveredFilers()
if len(filers) > 0 {
return filers[0] // Return the first available filer
}
return ""
}
// getDiscoveredFilers returns cached filers or discovers them from masters
func (s *AdminServer) getDiscoveredFilers() []string {
// Check if cache is still valid
if time.Since(s.lastFilerUpdate) < s.filerCacheExpiration && len(s.cachedFilers) > 0 {
return s.cachedFilers
}
// Discover filers from masters
var filers []string
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.ListClusterNodes(context.Background(), &master_pb.ListClusterNodesRequest{
ClientType: cluster.FilerType,
})
if err != nil {
return err
}
for _, node := range resp.ClusterNodes {
filers = append(filers, node.Address)
}
return nil
})
if err != nil {
glog.Warningf("Failed to discover filers from master %s: %v", s.masterAddress, err)
// Return cached filers even if expired, better than nothing
return s.cachedFilers
}
// Update cache
s.cachedFilers = filers
s.lastFilerUpdate = time.Now()
return filers
}
// GetAllFilers returns all discovered filers
func (s *AdminServer) GetAllFilers() []string {
return s.getDiscoveredFilers()
}
+118
View File
@@ -0,0 +1,118 @@
package dash
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
// GetClusterTopology returns the current cluster topology with caching
func (s *AdminServer) GetClusterTopology() (*ClusterTopology, error) {
now := time.Now()
if s.cachedTopology != nil && now.Sub(s.lastCacheUpdate) < s.cacheExpiration {
return s.cachedTopology, nil
}
topology := &ClusterTopology{
UpdatedAt: now,
}
// Use gRPC only
err := s.getTopologyViaGRPC(topology)
if err != nil {
glog.Errorf("Failed to connect to master server %s: %v", s.masterAddress, err)
return nil, fmt.Errorf("gRPC topology request failed: %v", err)
}
// Cache the result
s.cachedTopology = topology
s.lastCacheUpdate = now
return topology, nil
}
// getTopologyViaGRPC gets topology using gRPC (original method)
func (s *AdminServer) getTopologyViaGRPC(topology *ClusterTopology) error {
// Get cluster status from master
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.VolumeList(context.Background(), &master_pb.VolumeListRequest{})
if err != nil {
glog.Errorf("Failed to get volume list from master %s: %v", s.masterAddress, err)
return err
}
if resp.TopologyInfo != nil {
// Process gRPC response
for _, dc := range resp.TopologyInfo.DataCenterInfos {
dataCenter := DataCenter{
ID: dc.Id,
Racks: []Rack{},
}
for _, rack := range dc.RackInfos {
rackObj := Rack{
ID: rack.Id,
Nodes: []VolumeServer{},
}
for _, node := range rack.DataNodeInfos {
// Calculate totals from disk infos
var totalVolumes int64
var totalMaxVolumes int64
var totalSize int64
var totalFiles int64
for _, diskInfo := range node.DiskInfos {
totalVolumes += diskInfo.VolumeCount
totalMaxVolumes += diskInfo.MaxVolumeCount
// Sum up individual volume information
for _, volInfo := range diskInfo.VolumeInfos {
totalSize += int64(volInfo.Size)
totalFiles += int64(volInfo.FileCount)
}
}
vs := VolumeServer{
ID: node.Id,
Address: node.Id,
DataCenter: dc.Id,
Rack: rack.Id,
PublicURL: node.Id,
Volumes: int(totalVolumes),
MaxVolumes: int(totalMaxVolumes),
DiskUsage: totalSize,
DiskCapacity: totalMaxVolumes * int64(resp.VolumeSizeLimitMb) * 1024 * 1024,
LastHeartbeat: time.Now(),
}
rackObj.Nodes = append(rackObj.Nodes, vs)
topology.VolumeServers = append(topology.VolumeServers, vs)
topology.TotalVolumes += vs.Volumes
topology.TotalFiles += totalFiles
topology.TotalSize += totalSize
}
dataCenter.Racks = append(dataCenter.Racks, rackObj)
}
topology.DataCenters = append(topology.DataCenters, dataCenter)
}
}
return nil
})
return err
}
// InvalidateCache forces a refresh of cached data
func (s *AdminServer) InvalidateCache() {
s.lastCacheUpdate = time.Time{}
s.cachedTopology = nil
s.lastFilerUpdate = time.Time{}
s.cachedFilers = nil
}
+129
View File
@@ -0,0 +1,129 @@
package dash
import (
"context"
"sort"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
// GetClusterCollections retrieves cluster collections data
func (s *AdminServer) GetClusterCollections() (*ClusterCollectionsData, error) {
var collections []CollectionInfo
var totalVolumes int
var totalFiles int64
var totalSize int64
collectionMap := make(map[string]*CollectionInfo)
// Get actual collection information from volume data
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.VolumeList(context.Background(), &master_pb.VolumeListRequest{})
if err != nil {
return err
}
if resp.TopologyInfo != nil {
for _, dc := range resp.TopologyInfo.DataCenterInfos {
for _, rack := range dc.RackInfos {
for _, node := range rack.DataNodeInfos {
for _, diskInfo := range node.DiskInfos {
for _, volInfo := range diskInfo.VolumeInfos {
// Extract collection name from volume info
collectionName := volInfo.Collection
if collectionName == "" {
collectionName = "default" // Default collection for volumes without explicit collection
}
// Get disk type from volume info, default to hdd if empty
diskType := volInfo.DiskType
if diskType == "" {
diskType = "hdd"
}
// Get or create collection info
if collection, exists := collectionMap[collectionName]; exists {
collection.VolumeCount++
collection.FileCount += int64(volInfo.FileCount)
collection.TotalSize += int64(volInfo.Size)
// Update data center if this collection spans multiple DCs
if collection.DataCenter != dc.Id && collection.DataCenter != "multi" {
collection.DataCenter = "multi"
}
// Add disk type if not already present
diskTypeExists := false
for _, existingDiskType := range collection.DiskTypes {
if existingDiskType == diskType {
diskTypeExists = true
break
}
}
if !diskTypeExists {
collection.DiskTypes = append(collection.DiskTypes, diskType)
}
totalVolumes++
totalFiles += int64(volInfo.FileCount)
totalSize += int64(volInfo.Size)
} else {
newCollection := CollectionInfo{
Name: collectionName,
DataCenter: dc.Id,
VolumeCount: 1,
FileCount: int64(volInfo.FileCount),
TotalSize: int64(volInfo.Size),
DiskTypes: []string{diskType},
}
collectionMap[collectionName] = &newCollection
totalVolumes++
totalFiles += int64(volInfo.FileCount)
totalSize += int64(volInfo.Size)
}
}
}
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
// Convert map to slice
for _, collection := range collectionMap {
collections = append(collections, *collection)
}
// Sort collections alphabetically by name
sort.Slice(collections, func(i, j int) bool {
return collections[i].Name < collections[j].Name
})
// If no collections found, show a message indicating no collections exist
if len(collections) == 0 {
// Return empty collections data instead of creating fake ones
return &ClusterCollectionsData{
Collections: []CollectionInfo{},
TotalCollections: 0,
TotalVolumes: 0,
TotalFiles: 0,
TotalSize: 0,
LastUpdated: time.Now(),
}, nil
}
return &ClusterCollectionsData{
Collections: collections,
TotalCollections: len(collections),
TotalVolumes: totalVolumes,
TotalFiles: totalFiles,
TotalSize: totalSize,
LastUpdated: time.Now(),
}, nil
}
+270
View File
@@ -0,0 +1,270 @@
package dash
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
const (
// Configuration file names
MaintenanceConfigFile = "maintenance.json"
AdminConfigFile = "admin.json"
ConfigDirPermissions = 0755
ConfigFilePermissions = 0644
)
// ConfigPersistence handles saving and loading configuration files
type ConfigPersistence struct {
dataDir string
}
// NewConfigPersistence creates a new configuration persistence manager
func NewConfigPersistence(dataDir string) *ConfigPersistence {
return &ConfigPersistence{
dataDir: dataDir,
}
}
// SaveMaintenanceConfig saves maintenance configuration to JSON file
func (cp *ConfigPersistence) SaveMaintenanceConfig(config *MaintenanceConfig) error {
if cp.dataDir == "" {
return fmt.Errorf("no data directory specified, cannot save configuration")
}
configPath := filepath.Join(cp.dataDir, MaintenanceConfigFile)
// Create directory if it doesn't exist
if err := os.MkdirAll(cp.dataDir, ConfigDirPermissions); err != nil {
return fmt.Errorf("failed to create config directory: %v", err)
}
// Marshal configuration to JSON
configData, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal maintenance config: %v", err)
}
// Write to file
if err := os.WriteFile(configPath, configData, ConfigFilePermissions); err != nil {
return fmt.Errorf("failed to write maintenance config file: %v", err)
}
glog.V(1).Infof("Saved maintenance configuration to %s", configPath)
return nil
}
// LoadMaintenanceConfig loads maintenance configuration from JSON file
func (cp *ConfigPersistence) LoadMaintenanceConfig() (*MaintenanceConfig, error) {
if cp.dataDir == "" {
glog.V(1).Infof("No data directory specified, using default maintenance configuration")
return DefaultMaintenanceConfig(), nil
}
configPath := filepath.Join(cp.dataDir, MaintenanceConfigFile)
// Check if file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
glog.V(1).Infof("Maintenance config file does not exist, using defaults: %s", configPath)
return DefaultMaintenanceConfig(), nil
}
// Read file
configData, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read maintenance config file: %v", err)
}
// Unmarshal JSON
var config MaintenanceConfig
if err := json.Unmarshal(configData, &config); err != nil {
return nil, fmt.Errorf("failed to unmarshal maintenance config: %v", err)
}
glog.V(1).Infof("Loaded maintenance configuration from %s", configPath)
return &config, nil
}
// SaveAdminConfig saves general admin configuration to JSON file
func (cp *ConfigPersistence) SaveAdminConfig(config map[string]interface{}) error {
if cp.dataDir == "" {
return fmt.Errorf("no data directory specified, cannot save configuration")
}
configPath := filepath.Join(cp.dataDir, AdminConfigFile)
// Create directory if it doesn't exist
if err := os.MkdirAll(cp.dataDir, ConfigDirPermissions); err != nil {
return fmt.Errorf("failed to create config directory: %v", err)
}
// Marshal configuration to JSON
configData, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal admin config: %v", err)
}
// Write to file
if err := os.WriteFile(configPath, configData, ConfigFilePermissions); err != nil {
return fmt.Errorf("failed to write admin config file: %v", err)
}
glog.V(1).Infof("Saved admin configuration to %s", configPath)
return nil
}
// LoadAdminConfig loads general admin configuration from JSON file
func (cp *ConfigPersistence) LoadAdminConfig() (map[string]interface{}, error) {
if cp.dataDir == "" {
glog.V(1).Infof("No data directory specified, using default admin configuration")
return make(map[string]interface{}), nil
}
configPath := filepath.Join(cp.dataDir, AdminConfigFile)
// Check if file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
glog.V(1).Infof("Admin config file does not exist, using defaults: %s", configPath)
return make(map[string]interface{}), nil
}
// Read file
configData, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read admin config file: %v", err)
}
// Unmarshal JSON
var config map[string]interface{}
if err := json.Unmarshal(configData, &config); err != nil {
return nil, fmt.Errorf("failed to unmarshal admin config: %v", err)
}
glog.V(1).Infof("Loaded admin configuration from %s", configPath)
return config, nil
}
// GetConfigPath returns the path to a configuration file
func (cp *ConfigPersistence) GetConfigPath(filename string) string {
if cp.dataDir == "" {
return ""
}
return filepath.Join(cp.dataDir, filename)
}
// ListConfigFiles returns all configuration files in the data directory
func (cp *ConfigPersistence) ListConfigFiles() ([]string, error) {
if cp.dataDir == "" {
return nil, fmt.Errorf("no data directory specified")
}
files, err := os.ReadDir(cp.dataDir)
if err != nil {
return nil, fmt.Errorf("failed to read config directory: %v", err)
}
var configFiles []string
for _, file := range files {
if !file.IsDir() && filepath.Ext(file.Name()) == ".json" {
configFiles = append(configFiles, file.Name())
}
}
return configFiles, nil
}
// BackupConfig creates a backup of a configuration file
func (cp *ConfigPersistence) BackupConfig(filename string) error {
if cp.dataDir == "" {
return fmt.Errorf("no data directory specified")
}
configPath := filepath.Join(cp.dataDir, filename)
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return fmt.Errorf("config file does not exist: %s", filename)
}
// Create backup filename with timestamp
timestamp := time.Now().Format("2006-01-02_15-04-05")
backupName := fmt.Sprintf("%s.backup_%s", filename, timestamp)
backupPath := filepath.Join(cp.dataDir, backupName)
// Copy file
configData, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config file: %v", err)
}
if err := os.WriteFile(backupPath, configData, ConfigFilePermissions); err != nil {
return fmt.Errorf("failed to create backup: %v", err)
}
glog.V(1).Infof("Created backup of %s as %s", filename, backupName)
return nil
}
// RestoreConfig restores a configuration file from a backup
func (cp *ConfigPersistence) RestoreConfig(filename, backupName string) error {
if cp.dataDir == "" {
return fmt.Errorf("no data directory specified")
}
backupPath := filepath.Join(cp.dataDir, backupName)
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
return fmt.Errorf("backup file does not exist: %s", backupName)
}
// Read backup file
backupData, err := os.ReadFile(backupPath)
if err != nil {
return fmt.Errorf("failed to read backup file: %v", err)
}
// Write to config file
configPath := filepath.Join(cp.dataDir, filename)
if err := os.WriteFile(configPath, backupData, ConfigFilePermissions); err != nil {
return fmt.Errorf("failed to restore config: %v", err)
}
glog.V(1).Infof("Restored %s from backup %s", filename, backupName)
return nil
}
// GetDataDir returns the data directory path
func (cp *ConfigPersistence) GetDataDir() string {
return cp.dataDir
}
// IsConfigured returns true if a data directory is configured
func (cp *ConfigPersistence) IsConfigured() bool {
return cp.dataDir != ""
}
// GetConfigInfo returns information about the configuration storage
func (cp *ConfigPersistence) GetConfigInfo() map[string]interface{} {
info := map[string]interface{}{
"data_dir_configured": cp.IsConfigured(),
"data_dir": cp.dataDir,
}
if cp.IsConfigured() {
// Check if data directory exists
if _, err := os.Stat(cp.dataDir); err == nil {
info["data_dir_exists"] = true
// List config files
configFiles, err := cp.ListConfigFiles()
if err == nil {
info["config_files"] = configFiles
}
} else {
info["data_dir_exists"] = false
}
}
return info
}
+350
View File
@@ -0,0 +1,350 @@
package dash
import (
"context"
"path/filepath"
"sort"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// FileEntry represents a file or directory entry in the file browser
type FileEntry struct {
Name string `json:"name"`
FullPath string `json:"full_path"`
IsDirectory bool `json:"is_directory"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
Mode string `json:"mode"`
Uid uint32 `json:"uid"`
Gid uint32 `json:"gid"`
Mime string `json:"mime"`
Replication string `json:"replication"`
Collection string `json:"collection"`
TtlSec int32 `json:"ttl_sec"`
}
// BreadcrumbItem represents a single breadcrumb in the navigation
type BreadcrumbItem struct {
Name string `json:"name"`
Path string `json:"path"`
}
// FileBrowserData contains all data needed for the file browser view
type FileBrowserData struct {
Username string `json:"username"`
CurrentPath string `json:"current_path"`
ParentPath string `json:"parent_path"`
Breadcrumbs []BreadcrumbItem `json:"breadcrumbs"`
Entries []FileEntry `json:"entries"`
TotalEntries int `json:"total_entries"`
TotalSize int64 `json:"total_size"`
LastUpdated time.Time `json:"last_updated"`
IsBucketPath bool `json:"is_bucket_path"`
BucketName string `json:"bucket_name"`
}
// GetFileBrowser retrieves file browser data for a given path
func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) {
if path == "" {
path = "/"
}
var entries []FileEntry
var totalSize int64
// Get directory listing from filer
err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
Directory: path,
Prefix: "",
Limit: 1000,
InclusiveStartFrom: false,
})
if err != nil {
return err
}
for {
resp, err := stream.Recv()
if err != nil {
if err.Error() == "EOF" {
break
}
return err
}
entry := resp.Entry
if entry == nil {
continue
}
fullPath := path
if !strings.HasSuffix(fullPath, "/") {
fullPath += "/"
}
fullPath += entry.Name
var modTime time.Time
if entry.Attributes != nil && entry.Attributes.Mtime > 0 {
modTime = time.Unix(entry.Attributes.Mtime, 0)
}
var mode string
var uid, gid uint32
var size int64
var replication, collection string
var ttlSec int32
if entry.Attributes != nil {
mode = formatFileMode(entry.Attributes.FileMode)
uid = entry.Attributes.Uid
gid = entry.Attributes.Gid
size = int64(entry.Attributes.FileSize)
ttlSec = entry.Attributes.TtlSec
}
// Get replication and collection from entry extended attributes or chunks
if entry.Extended != nil {
if repl, ok := entry.Extended["replication"]; ok {
replication = string(repl)
}
if coll, ok := entry.Extended["collection"]; ok {
collection = string(coll)
}
}
// Determine MIME type based on file extension
mime := "application/octet-stream"
if entry.IsDirectory {
mime = "inode/directory"
} else {
ext := strings.ToLower(filepath.Ext(entry.Name))
switch ext {
case ".txt", ".log":
mime = "text/plain"
case ".html", ".htm":
mime = "text/html"
case ".css":
mime = "text/css"
case ".js":
mime = "application/javascript"
case ".json":
mime = "application/json"
case ".xml":
mime = "application/xml"
case ".pdf":
mime = "application/pdf"
case ".jpg", ".jpeg":
mime = "image/jpeg"
case ".png":
mime = "image/png"
case ".gif":
mime = "image/gif"
case ".svg":
mime = "image/svg+xml"
case ".mp4":
mime = "video/mp4"
case ".mp3":
mime = "audio/mpeg"
case ".zip":
mime = "application/zip"
case ".tar":
mime = "application/x-tar"
case ".gz":
mime = "application/gzip"
}
}
fileEntry := FileEntry{
Name: entry.Name,
FullPath: fullPath,
IsDirectory: entry.IsDirectory,
Size: size,
ModTime: modTime,
Mode: mode,
Uid: uid,
Gid: gid,
Mime: mime,
Replication: replication,
Collection: collection,
TtlSec: ttlSec,
}
entries = append(entries, fileEntry)
if !entry.IsDirectory {
totalSize += size
}
}
return nil
})
if err != nil {
return nil, err
}
// Sort entries: directories first, then files, both alphabetically
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDirectory != entries[j].IsDirectory {
return entries[i].IsDirectory
}
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
})
// Generate breadcrumbs
breadcrumbs := s.generateBreadcrumbs(path)
// Calculate parent path
parentPath := "/"
if path != "/" {
parentPath = filepath.Dir(path)
if parentPath == "." {
parentPath = "/"
}
}
// Check if this is a bucket path
isBucketPath := false
bucketName := ""
if strings.HasPrefix(path, "/buckets/") {
isBucketPath = true
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(pathParts) >= 2 {
bucketName = pathParts[1]
}
}
return &FileBrowserData{
CurrentPath: path,
ParentPath: parentPath,
Breadcrumbs: breadcrumbs,
Entries: entries,
TotalEntries: len(entries),
TotalSize: totalSize,
LastUpdated: time.Now(),
IsBucketPath: isBucketPath,
BucketName: bucketName,
}, nil
}
// generateBreadcrumbs creates breadcrumb navigation for the current path
func (s *AdminServer) generateBreadcrumbs(path string) []BreadcrumbItem {
var breadcrumbs []BreadcrumbItem
// Always start with root
breadcrumbs = append(breadcrumbs, BreadcrumbItem{
Name: "Root",
Path: "/",
})
if path == "/" {
return breadcrumbs
}
// Split path and build breadcrumbs
parts := strings.Split(strings.Trim(path, "/"), "/")
currentPath := ""
for _, part := range parts {
if part == "" {
continue
}
currentPath += "/" + part
// Special handling for bucket paths
displayName := part
if len(breadcrumbs) == 1 && part == "buckets" {
displayName = "Object Store Buckets"
} else if len(breadcrumbs) == 2 && strings.HasPrefix(path, "/buckets/") {
displayName = "📦 " + part // Add bucket icon to bucket name
}
breadcrumbs = append(breadcrumbs, BreadcrumbItem{
Name: displayName,
Path: currentPath,
})
}
return breadcrumbs
}
// formatFileMode converts file mode to Unix-style string representation (e.g., "drwxr-xr-x")
func formatFileMode(mode uint32) string {
var result []byte = make([]byte, 10)
// File type
switch mode & 0170000 { // S_IFMT mask
case 0040000: // S_IFDIR
result[0] = 'd'
case 0100000: // S_IFREG
result[0] = '-'
case 0120000: // S_IFLNK
result[0] = 'l'
case 0020000: // S_IFCHR
result[0] = 'c'
case 0060000: // S_IFBLK
result[0] = 'b'
case 0010000: // S_IFIFO
result[0] = 'p'
case 0140000: // S_IFSOCK
result[0] = 's'
default:
result[0] = '-' // S_IFREG is default
}
// Owner permissions
if mode&0400 != 0 { // S_IRUSR
result[1] = 'r'
} else {
result[1] = '-'
}
if mode&0200 != 0 { // S_IWUSR
result[2] = 'w'
} else {
result[2] = '-'
}
if mode&0100 != 0 { // S_IXUSR
result[3] = 'x'
} else {
result[3] = '-'
}
// Group permissions
if mode&0040 != 0 { // S_IRGRP
result[4] = 'r'
} else {
result[4] = '-'
}
if mode&0020 != 0 { // S_IWGRP
result[5] = 'w'
} else {
result[5] = '-'
}
if mode&0010 != 0 { // S_IXGRP
result[6] = 'x'
} else {
result[6] = '-'
}
// Other permissions
if mode&0004 != 0 { // S_IROTH
result[7] = 'r'
} else {
result[7] = '-'
}
if mode&0002 != 0 { // S_IWOTH
result[8] = 'w'
} else {
result[8] = '-'
}
if mode&0001 != 0 { // S_IXOTH
result[9] = 'x'
} else {
result[9] = '-'
}
return string(result)
}
+27
View File
@@ -0,0 +1,27 @@
package dash
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// RequireAuth checks if user is authenticated
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
authenticated := session.Get("authenticated")
username := session.Get("username")
if authenticated != true || username == nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
return
}
// Set username in context for use in handlers
c.Set("username", username)
c.Next()
}
}
+248
View File
@@ -0,0 +1,248 @@
package dash
import (
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
// Core cluster topology structures
type ClusterTopology struct {
Masters []MasterNode `json:"masters"`
DataCenters []DataCenter `json:"datacenters"`
VolumeServers []VolumeServer `json:"volume_servers"`
TotalVolumes int `json:"total_volumes"`
TotalFiles int64 `json:"total_files"`
TotalSize int64 `json:"total_size"`
UpdatedAt time.Time `json:"updated_at"`
}
type MasterNode struct {
Address string `json:"address"`
IsLeader bool `json:"is_leader"`
}
type DataCenter struct {
ID string `json:"id"`
Racks []Rack `json:"racks"`
}
type Rack struct {
ID string `json:"id"`
Nodes []VolumeServer `json:"nodes"`
}
type VolumeServer struct {
ID string `json:"id"`
Address string `json:"address"`
DataCenter string `json:"datacenter"`
Rack string `json:"rack"`
PublicURL string `json:"public_url"`
Volumes int `json:"volumes"`
MaxVolumes int `json:"max_volumes"`
DiskUsage int64 `json:"disk_usage"`
DiskCapacity int64 `json:"disk_capacity"`
LastHeartbeat time.Time `json:"last_heartbeat"`
}
// S3 Bucket management structures
type S3Bucket struct {
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Size int64 `json:"size"`
ObjectCount int64 `json:"object_count"`
LastModified time.Time `json:"last_modified"`
Quota int64 `json:"quota"` // Quota in bytes, 0 means no quota
QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled
}
type S3Object struct {
Key string `json:"key"`
Size int64 `json:"size"`
LastModified time.Time `json:"last_modified"`
ETag string `json:"etag"`
StorageClass string `json:"storage_class"`
}
type BucketDetails struct {
Bucket S3Bucket `json:"bucket"`
Objects []S3Object `json:"objects"`
TotalSize int64 `json:"total_size"`
TotalCount int64 `json:"total_count"`
UpdatedAt time.Time `json:"updated_at"`
}
// ObjectStoreUser is defined in admin_data.go
// Volume management structures
type VolumeWithTopology struct {
*master_pb.VolumeInformationMessage
Server string `json:"server"`
DataCenter string `json:"datacenter"`
Rack string `json:"rack"`
}
type ClusterVolumesData struct {
Username string `json:"username"`
Volumes []VolumeWithTopology `json:"volumes"`
TotalVolumes int `json:"total_volumes"`
TotalSize int64 `json:"total_size"`
VolumeSizeLimit uint64 `json:"volume_size_limit"`
LastUpdated time.Time `json:"last_updated"`
// Pagination
CurrentPage int `json:"current_page"`
TotalPages int `json:"total_pages"`
PageSize int `json:"page_size"`
// Sorting
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
// Statistics
DataCenterCount int `json:"datacenter_count"`
RackCount int `json:"rack_count"`
DiskTypeCount int `json:"disk_type_count"`
CollectionCount int `json:"collection_count"`
VersionCount int `json:"version_count"`
// Conditional display flags
ShowDataCenterColumn bool `json:"show_datacenter_column"`
ShowRackColumn bool `json:"show_rack_column"`
ShowDiskTypeColumn bool `json:"show_disk_type_column"`
ShowCollectionColumn bool `json:"show_collection_column"`
ShowVersionColumn bool `json:"show_version_column"`
// Single values when only one exists
SingleDataCenter string `json:"single_datacenter"`
SingleRack string `json:"single_rack"`
SingleDiskType string `json:"single_disk_type"`
SingleCollection string `json:"single_collection"`
SingleVersion string `json:"single_version"`
// All versions when multiple exist
AllVersions []string `json:"all_versions"`
// All disk types when multiple exist
AllDiskTypes []string `json:"all_disk_types"`
// Filtering
FilterCollection string `json:"filter_collection"`
}
type VolumeDetailsData struct {
Volume VolumeWithTopology `json:"volume"`
Replicas []VolumeWithTopology `json:"replicas"`
VolumeSizeLimit uint64 `json:"volume_size_limit"`
ReplicationCount int `json:"replication_count"`
LastUpdated time.Time `json:"last_updated"`
}
// Collection management structures
type CollectionInfo struct {
Name string `json:"name"`
DataCenter string `json:"datacenter"`
VolumeCount int `json:"volume_count"`
FileCount int64 `json:"file_count"`
TotalSize int64 `json:"total_size"`
DiskTypes []string `json:"disk_types"`
}
type ClusterCollectionsData struct {
Username string `json:"username"`
Collections []CollectionInfo `json:"collections"`
TotalCollections int `json:"total_collections"`
TotalVolumes int `json:"total_volumes"`
TotalFiles int64 `json:"total_files"`
TotalSize int64 `json:"total_size"`
LastUpdated time.Time `json:"last_updated"`
}
// Master and Filer management structures
type MasterInfo struct {
Address string `json:"address"`
IsLeader bool `json:"is_leader"`
Suffrage string `json:"suffrage"`
}
type ClusterMastersData struct {
Username string `json:"username"`
Masters []MasterInfo `json:"masters"`
TotalMasters int `json:"total_masters"`
LeaderCount int `json:"leader_count"`
LastUpdated time.Time `json:"last_updated"`
}
type FilerInfo struct {
Address string `json:"address"`
DataCenter string `json:"datacenter"`
Rack string `json:"rack"`
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
type ClusterFilersData struct {
Username string `json:"username"`
Filers []FilerInfo `json:"filers"`
TotalFilers int `json:"total_filers"`
LastUpdated time.Time `json:"last_updated"`
}
// Volume server management structures
type ClusterVolumeServersData struct {
Username string `json:"username"`
VolumeServers []VolumeServer `json:"volume_servers"`
TotalVolumeServers int `json:"total_volume_servers"`
TotalVolumes int `json:"total_volumes"`
TotalCapacity int64 `json:"total_capacity"`
LastUpdated time.Time `json:"last_updated"`
}
// Type aliases for maintenance package types to support existing code
type MaintenanceTask = maintenance.MaintenanceTask
type MaintenanceTaskType = maintenance.MaintenanceTaskType
type MaintenanceTaskStatus = maintenance.MaintenanceTaskStatus
type MaintenanceTaskPriority = maintenance.MaintenanceTaskPriority
type MaintenanceWorker = maintenance.MaintenanceWorker
type MaintenanceConfig = maintenance.MaintenanceConfig
type MaintenanceStats = maintenance.MaintenanceStats
type MaintenanceConfigData = maintenance.MaintenanceConfigData
type MaintenanceQueueData = maintenance.MaintenanceQueueData
type QueueStats = maintenance.QueueStats
type WorkerDetailsData = maintenance.WorkerDetailsData
type WorkerPerformance = maintenance.WorkerPerformance
// GetTaskIcon returns the icon CSS class for a task type from its UI provider
func GetTaskIcon(taskType MaintenanceTaskType) string {
return maintenance.GetTaskIcon(taskType)
}
// Status constants (these are still static)
const (
TaskStatusPending = maintenance.TaskStatusPending
TaskStatusAssigned = maintenance.TaskStatusAssigned
TaskStatusInProgress = maintenance.TaskStatusInProgress
TaskStatusCompleted = maintenance.TaskStatusCompleted
TaskStatusFailed = maintenance.TaskStatusFailed
TaskStatusCancelled = maintenance.TaskStatusCancelled
PriorityLow = maintenance.PriorityLow
PriorityNormal = maintenance.PriorityNormal
PriorityHigh = maintenance.PriorityHigh
PriorityCritical = maintenance.PriorityCritical
)
// Helper functions from maintenance package
var DefaultMaintenanceConfig = maintenance.DefaultMaintenanceConfig
// MaintenanceWorkersData represents the data for the maintenance workers page
type MaintenanceWorkersData struct {
Workers []*WorkerDetailsData `json:"workers"`
ActiveWorkers int `json:"active_workers"`
BusyWorkers int `json:"busy_workers"`
TotalLoad int `json:"total_load"`
LastUpdated time.Time `json:"last_updated"`
}
// Maintenance system types are now in weed/admin/maintenance package
+338
View File
@@ -0,0 +1,338 @@
package dash
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
// CreateObjectStoreUser creates a new user using the credential manager
func (s *AdminServer) CreateObjectStoreUser(req CreateUserRequest) (*ObjectStoreUser, error) {
if s.credentialManager == nil {
return nil, fmt.Errorf("credential manager not available")
}
ctx := context.Background()
// Create new identity
newIdentity := &iam_pb.Identity{
Name: req.Username,
Actions: req.Actions,
}
// Add account if email is provided
if req.Email != "" {
newIdentity.Account = &iam_pb.Account{
Id: generateAccountId(),
DisplayName: req.Username,
EmailAddress: req.Email,
}
}
// Generate access key if requested
var accessKey, secretKey string
if req.GenerateKey {
accessKey = generateAccessKey()
secretKey = generateSecretKey()
newIdentity.Credentials = []*iam_pb.Credential{
{
AccessKey: accessKey,
SecretKey: secretKey,
},
}
}
// Create user using credential manager
err := s.credentialManager.CreateUser(ctx, newIdentity)
if err != nil {
if err == credential.ErrUserAlreadyExists {
return nil, fmt.Errorf("user %s already exists", req.Username)
}
return nil, fmt.Errorf("failed to create user: %v", err)
}
// Return created user
user := &ObjectStoreUser{
Username: req.Username,
Email: req.Email,
AccessKey: accessKey,
SecretKey: secretKey,
Permissions: req.Actions,
}
return user, nil
}
// UpdateObjectStoreUser updates an existing user
func (s *AdminServer) UpdateObjectStoreUser(username string, req UpdateUserRequest) (*ObjectStoreUser, error) {
if s.credentialManager == nil {
return nil, fmt.Errorf("credential manager not available")
}
ctx := context.Background()
// Get existing user
identity, err := s.credentialManager.GetUser(ctx, username)
if err != nil {
if err == credential.ErrUserNotFound {
return nil, fmt.Errorf("user %s not found", username)
}
return nil, fmt.Errorf("failed to get user: %v", err)
}
// Create updated identity
updatedIdentity := &iam_pb.Identity{
Name: identity.Name,
Account: identity.Account,
Credentials: identity.Credentials,
Actions: identity.Actions,
}
// Update actions if provided
if len(req.Actions) > 0 {
updatedIdentity.Actions = req.Actions
}
// Update email if provided
if req.Email != "" {
if updatedIdentity.Account == nil {
updatedIdentity.Account = &iam_pb.Account{
Id: generateAccountId(),
DisplayName: username,
}
}
updatedIdentity.Account.EmailAddress = req.Email
}
// Update user using credential manager
err = s.credentialManager.UpdateUser(ctx, username, updatedIdentity)
if err != nil {
return nil, fmt.Errorf("failed to update user: %v", err)
}
// Return updated user
user := &ObjectStoreUser{
Username: username,
Email: req.Email,
Permissions: updatedIdentity.Actions,
}
// Get first access key for display
if len(updatedIdentity.Credentials) > 0 {
user.AccessKey = updatedIdentity.Credentials[0].AccessKey
user.SecretKey = updatedIdentity.Credentials[0].SecretKey
}
return user, nil
}
// DeleteObjectStoreUser deletes a user using the credential manager
func (s *AdminServer) DeleteObjectStoreUser(username string) error {
if s.credentialManager == nil {
return fmt.Errorf("credential manager not available")
}
ctx := context.Background()
// Delete user using credential manager
err := s.credentialManager.DeleteUser(ctx, username)
if err != nil {
if err == credential.ErrUserNotFound {
return fmt.Errorf("user %s not found", username)
}
return fmt.Errorf("failed to delete user: %v", err)
}
return nil
}
// GetObjectStoreUserDetails returns detailed information about a user
func (s *AdminServer) GetObjectStoreUserDetails(username string) (*UserDetails, error) {
if s.credentialManager == nil {
return nil, fmt.Errorf("credential manager not available")
}
ctx := context.Background()
// Get user using credential manager
identity, err := s.credentialManager.GetUser(ctx, username)
if err != nil {
if err == credential.ErrUserNotFound {
return nil, fmt.Errorf("user %s not found", username)
}
return nil, fmt.Errorf("failed to get user: %v", err)
}
details := &UserDetails{
Username: username,
Actions: identity.Actions,
}
// Set email from account if available
if identity.Account != nil {
details.Email = identity.Account.EmailAddress
}
// Convert credentials to access key info
for _, cred := range identity.Credentials {
details.AccessKeys = append(details.AccessKeys, AccessKeyInfo{
AccessKey: cred.AccessKey,
SecretKey: cred.SecretKey,
CreatedAt: time.Now().AddDate(0, -1, 0), // Mock creation date
})
}
return details, nil
}
// CreateAccessKey creates a new access key for a user
func (s *AdminServer) CreateAccessKey(username string) (*AccessKeyInfo, error) {
if s.credentialManager == nil {
return nil, fmt.Errorf("credential manager not available")
}
ctx := context.Background()
// Check if user exists
_, err := s.credentialManager.GetUser(ctx, username)
if err != nil {
if err == credential.ErrUserNotFound {
return nil, fmt.Errorf("user %s not found", username)
}
return nil, fmt.Errorf("failed to get user: %v", err)
}
// Generate new access key
accessKey := generateAccessKey()
secretKey := generateSecretKey()
credential := &iam_pb.Credential{
AccessKey: accessKey,
SecretKey: secretKey,
}
// Create access key using credential manager
err = s.credentialManager.CreateAccessKey(ctx, username, credential)
if err != nil {
return nil, fmt.Errorf("failed to create access key: %v", err)
}
return &AccessKeyInfo{
AccessKey: accessKey,
SecretKey: secretKey,
CreatedAt: time.Now(),
}, nil
}
// DeleteAccessKey deletes an access key for a user
func (s *AdminServer) DeleteAccessKey(username, accessKeyId string) error {
if s.credentialManager == nil {
return fmt.Errorf("credential manager not available")
}
ctx := context.Background()
// Delete access key using credential manager
err := s.credentialManager.DeleteAccessKey(ctx, username, accessKeyId)
if err != nil {
if err == credential.ErrUserNotFound {
return fmt.Errorf("user %s not found", username)
}
if err == credential.ErrAccessKeyNotFound {
return fmt.Errorf("access key %s not found for user %s", accessKeyId, username)
}
return fmt.Errorf("failed to delete access key: %v", err)
}
return nil
}
// GetUserPolicies returns the policies for a user (actions)
func (s *AdminServer) GetUserPolicies(username string) ([]string, error) {
if s.credentialManager == nil {
return nil, fmt.Errorf("credential manager not available")
}
ctx := context.Background()
// Get user using credential manager
identity, err := s.credentialManager.GetUser(ctx, username)
if err != nil {
if err == credential.ErrUserNotFound {
return nil, fmt.Errorf("user %s not found", username)
}
return nil, fmt.Errorf("failed to get user: %v", err)
}
return identity.Actions, nil
}
// UpdateUserPolicies updates the policies (actions) for a user
func (s *AdminServer) UpdateUserPolicies(username string, actions []string) error {
if s.credentialManager == nil {
return fmt.Errorf("credential manager not available")
}
ctx := context.Background()
// Get existing user
identity, err := s.credentialManager.GetUser(ctx, username)
if err != nil {
if err == credential.ErrUserNotFound {
return fmt.Errorf("user %s not found", username)
}
return fmt.Errorf("failed to get user: %v", err)
}
// Create updated identity with new actions
updatedIdentity := &iam_pb.Identity{
Name: identity.Name,
Account: identity.Account,
Credentials: identity.Credentials,
Actions: actions,
}
// Update user using credential manager
err = s.credentialManager.UpdateUser(ctx, username, updatedIdentity)
if err != nil {
return fmt.Errorf("failed to update user policies: %v", err)
}
return nil
}
// Helper functions for generating keys and IDs
func generateAccessKey() string {
// Generate 20-character access key (AWS standard)
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 20)
for i := range b {
b[i] = charset[randomInt(len(charset))]
}
return string(b)
}
func generateSecretKey() string {
// Generate 40-character secret key (AWS standard)
b := make([]byte, 30) // 30 bytes = 40 characters in base64
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}
func generateAccountId() string {
// Generate 12-digit account ID
b := make([]byte, 8)
rand.Read(b)
return fmt.Sprintf("%012d", b[0]<<24|b[1]<<16|b[2]<<8|b[3])
}
func randomInt(max int) int {
b := make([]byte, 1)
rand.Read(b)
return int(b[0]) % max
}
+394
View File
@@ -0,0 +1,394 @@
package dash
import (
"context"
"fmt"
"sort"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
// GetClusterVolumes retrieves cluster volumes data with pagination, sorting, and filtering
func (s *AdminServer) GetClusterVolumes(page int, pageSize int, sortBy string, sortOrder string, collection string) (*ClusterVolumesData, error) {
// Set defaults
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 1000 {
pageSize = 100
}
if sortBy == "" {
sortBy = "id"
}
if sortOrder == "" {
sortOrder = "asc"
}
var volumes []VolumeWithTopology
var totalSize int64
// Get detailed volume information via gRPC
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.VolumeList(context.Background(), &master_pb.VolumeListRequest{})
if err != nil {
return err
}
if resp.TopologyInfo != nil {
for _, dc := range resp.TopologyInfo.DataCenterInfos {
for _, rack := range dc.RackInfos {
for _, node := range rack.DataNodeInfos {
for _, diskInfo := range node.DiskInfos {
for _, volInfo := range diskInfo.VolumeInfos {
volume := VolumeWithTopology{
VolumeInformationMessage: volInfo,
Server: node.Id,
DataCenter: dc.Id,
Rack: rack.Id,
}
volumes = append(volumes, volume)
totalSize += int64(volInfo.Size)
}
}
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
// Filter by collection if specified
if collection != "" {
var filteredVolumes []VolumeWithTopology
var filteredTotalSize int64
for _, volume := range volumes {
// Handle "default" collection filtering for empty collections
volumeCollection := volume.Collection
if volumeCollection == "" {
volumeCollection = "default"
}
if volumeCollection == collection {
filteredVolumes = append(filteredVolumes, volume)
filteredTotalSize += int64(volume.Size)
}
}
volumes = filteredVolumes
totalSize = filteredTotalSize
}
// Calculate unique data center, rack, disk type, collection, and version counts from filtered volumes
dataCenterMap := make(map[string]bool)
rackMap := make(map[string]bool)
diskTypeMap := make(map[string]bool)
collectionMap := make(map[string]bool)
versionMap := make(map[string]bool)
for _, volume := range volumes {
if volume.DataCenter != "" {
dataCenterMap[volume.DataCenter] = true
}
if volume.Rack != "" {
rackMap[volume.Rack] = true
}
diskType := volume.DiskType
if diskType == "" {
diskType = "hdd" // Default to hdd if not specified
}
diskTypeMap[diskType] = true
// Handle collection for display purposes
collectionName := volume.Collection
if collectionName == "" {
collectionName = "default"
}
collectionMap[collectionName] = true
versionMap[fmt.Sprintf("%d", volume.Version)] = true
}
dataCenterCount := len(dataCenterMap)
rackCount := len(rackMap)
diskTypeCount := len(diskTypeMap)
collectionCount := len(collectionMap)
versionCount := len(versionMap)
// Sort volumes
s.sortVolumes(volumes, sortBy, sortOrder)
// Get volume size limit from master
var volumeSizeLimit uint64
err = s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.GetMasterConfiguration(context.Background(), &master_pb.GetMasterConfigurationRequest{})
if err != nil {
return err
}
volumeSizeLimit = uint64(resp.VolumeSizeLimitMB) * 1024 * 1024 // Convert MB to bytes
return nil
})
if err != nil {
// If we can't get the limit, set a default
volumeSizeLimit = 30 * 1024 * 1024 * 1024 // 30GB default
}
// Calculate pagination
totalVolumes := len(volumes)
totalPages := (totalVolumes + pageSize - 1) / pageSize
if totalPages == 0 {
totalPages = 1
}
// Apply pagination
startIndex := (page - 1) * pageSize
endIndex := startIndex + pageSize
if startIndex >= totalVolumes {
volumes = []VolumeWithTopology{}
} else {
if endIndex > totalVolumes {
endIndex = totalVolumes
}
volumes = volumes[startIndex:endIndex]
}
// Determine conditional display flags and extract single values
showDataCenterColumn := dataCenterCount > 1
showRackColumn := rackCount > 1
showDiskTypeColumn := diskTypeCount > 1
showCollectionColumn := collectionCount > 1 && collection == "" // Hide column when filtering by collection
showVersionColumn := versionCount > 1
var singleDataCenter, singleRack, singleDiskType, singleCollection, singleVersion string
var allVersions, allDiskTypes []string
if dataCenterCount == 1 {
for dc := range dataCenterMap {
singleDataCenter = dc
break
}
}
if rackCount == 1 {
for rack := range rackMap {
singleRack = rack
break
}
}
if diskTypeCount == 1 {
for diskType := range diskTypeMap {
singleDiskType = diskType
break
}
} else {
// Collect all disk types and sort them
for diskType := range diskTypeMap {
allDiskTypes = append(allDiskTypes, diskType)
}
sort.Strings(allDiskTypes)
}
if collectionCount == 1 {
for collection := range collectionMap {
singleCollection = collection
break
}
}
if versionCount == 1 {
for version := range versionMap {
singleVersion = "v" + version
break
}
} else {
// Collect all versions and sort them
for version := range versionMap {
allVersions = append(allVersions, "v"+version)
}
sort.Strings(allVersions)
}
return &ClusterVolumesData{
Volumes: volumes,
TotalVolumes: totalVolumes,
TotalSize: totalSize,
VolumeSizeLimit: volumeSizeLimit,
LastUpdated: time.Now(),
CurrentPage: page,
TotalPages: totalPages,
PageSize: pageSize,
SortBy: sortBy,
SortOrder: sortOrder,
DataCenterCount: dataCenterCount,
RackCount: rackCount,
DiskTypeCount: diskTypeCount,
CollectionCount: collectionCount,
VersionCount: versionCount,
ShowDataCenterColumn: showDataCenterColumn,
ShowRackColumn: showRackColumn,
ShowDiskTypeColumn: showDiskTypeColumn,
ShowCollectionColumn: showCollectionColumn,
ShowVersionColumn: showVersionColumn,
SingleDataCenter: singleDataCenter,
SingleRack: singleRack,
SingleDiskType: singleDiskType,
SingleCollection: singleCollection,
SingleVersion: singleVersion,
AllVersions: allVersions,
AllDiskTypes: allDiskTypes,
FilterCollection: collection,
}, nil
}
// sortVolumes sorts the volumes slice based on the specified field and order
func (s *AdminServer) sortVolumes(volumes []VolumeWithTopology, sortBy string, sortOrder string) {
sort.Slice(volumes, func(i, j int) bool {
var less bool
switch sortBy {
case "id":
less = volumes[i].Id < volumes[j].Id
case "server":
less = volumes[i].Server < volumes[j].Server
case "datacenter":
less = volumes[i].DataCenter < volumes[j].DataCenter
case "rack":
less = volumes[i].Rack < volumes[j].Rack
case "collection":
less = volumes[i].Collection < volumes[j].Collection
case "size":
less = volumes[i].Size < volumes[j].Size
case "filecount":
less = volumes[i].FileCount < volumes[j].FileCount
case "replication":
less = volumes[i].ReplicaPlacement < volumes[j].ReplicaPlacement
case "disktype":
less = volumes[i].DiskType < volumes[j].DiskType
case "version":
less = volumes[i].Version < volumes[j].Version
default:
less = volumes[i].Id < volumes[j].Id
}
if sortOrder == "desc" {
return !less
}
return less
})
}
// GetVolumeDetails retrieves detailed information about a specific volume
func (s *AdminServer) GetVolumeDetails(volumeID int, server string) (*VolumeDetailsData, error) {
var primaryVolume VolumeWithTopology
var replicas []VolumeWithTopology
var volumeSizeLimit uint64
var found bool
// Find the volume and all its replicas in the cluster
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.VolumeList(context.Background(), &master_pb.VolumeListRequest{})
if err != nil {
return err
}
if resp.TopologyInfo != nil {
for _, dc := range resp.TopologyInfo.DataCenterInfos {
for _, rack := range dc.RackInfos {
for _, node := range rack.DataNodeInfos {
for _, diskInfo := range node.DiskInfos {
for _, volInfo := range diskInfo.VolumeInfos {
if int(volInfo.Id) == volumeID {
diskType := volInfo.DiskType
if diskType == "" {
diskType = "hdd"
}
volume := VolumeWithTopology{
VolumeInformationMessage: volInfo,
Server: node.Id,
DataCenter: dc.Id,
Rack: rack.Id,
}
// If this is the requested server, it's the primary volume
if node.Id == server {
primaryVolume = volume
found = true
} else {
// This is a replica on another server
replicas = append(replicas, volume)
}
}
}
}
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("volume %d not found on server %s", volumeID, server)
}
// Get volume size limit from master
err = s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.GetMasterConfiguration(context.Background(), &master_pb.GetMasterConfigurationRequest{})
if err != nil {
return err
}
volumeSizeLimit = uint64(resp.VolumeSizeLimitMB) * 1024 * 1024 // Convert MB to bytes
return nil
})
if err != nil {
// If we can't get the limit, set a default
volumeSizeLimit = 30 * 1024 * 1024 * 1024 // 30GB default
}
return &VolumeDetailsData{
Volume: primaryVolume,
Replicas: replicas,
VolumeSizeLimit: volumeSizeLimit,
ReplicationCount: len(replicas) + 1, // Include the primary volume
LastUpdated: time.Now(),
}, nil
}
// VacuumVolume performs a vacuum operation on a specific volume
func (s *AdminServer) VacuumVolume(volumeID int, server string) error {
return s.WithMasterClient(func(client master_pb.SeaweedClient) error {
_, err := client.VacuumVolume(context.Background(), &master_pb.VacuumVolumeRequest{
VolumeId: uint32(volumeID),
GarbageThreshold: 0.0001, // A very low threshold to ensure all garbage is collected
Collection: "", // Empty for all collections
})
return err
})
}
// GetClusterVolumeServers retrieves cluster volume servers data
func (s *AdminServer) GetClusterVolumeServers() (*ClusterVolumeServersData, error) {
topology, err := s.GetClusterTopology()
if err != nil {
return nil, err
}
var totalCapacity int64
var totalVolumes int
for _, vs := range topology.VolumeServers {
totalCapacity += vs.DiskCapacity
totalVolumes += vs.Volumes
}
return &ClusterVolumeServersData{
VolumeServers: topology.VolumeServers,
TotalVolumeServers: len(topology.VolumeServers),
TotalVolumes: totalVolumes,
TotalCapacity: totalCapacity,
LastUpdated: time.Now(),
}, nil
}
+461
View File
@@ -0,0 +1,461 @@
package dash
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
"google.golang.org/grpc/peer"
)
// WorkerGrpcServer implements the WorkerService gRPC interface
type WorkerGrpcServer struct {
worker_pb.UnimplementedWorkerServiceServer
adminServer *AdminServer
// Worker connection management
connections map[string]*WorkerConnection
connMutex sync.RWMutex
// gRPC server
grpcServer *grpc.Server
listener net.Listener
running bool
stopChan chan struct{}
}
// WorkerConnection represents an active worker connection
type WorkerConnection struct {
workerID string
stream worker_pb.WorkerService_WorkerStreamServer
lastSeen time.Time
capabilities []MaintenanceTaskType
address string
maxConcurrent int32
outgoing chan *worker_pb.AdminMessage
ctx context.Context
cancel context.CancelFunc
}
// NewWorkerGrpcServer creates a new gRPC server for worker connections
func NewWorkerGrpcServer(adminServer *AdminServer) *WorkerGrpcServer {
return &WorkerGrpcServer{
adminServer: adminServer,
connections: make(map[string]*WorkerConnection),
stopChan: make(chan struct{}),
}
}
// StartWithTLS starts the gRPC server on the specified port with optional TLS
func (s *WorkerGrpcServer) StartWithTLS(port int) error {
if s.running {
return fmt.Errorf("worker gRPC server is already running")
}
// Create listener
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return fmt.Errorf("failed to listen on port %d: %v", port, err)
}
// Create gRPC server with optional TLS
grpcServer := pb.NewGrpcServer(security.LoadServerTLS(util.GetViper(), "grpc.admin"))
worker_pb.RegisterWorkerServiceServer(grpcServer, s)
s.grpcServer = grpcServer
s.listener = listener
s.running = true
// Start cleanup routine
go s.cleanupRoutine()
// Start serving in a goroutine
go func() {
if err := s.grpcServer.Serve(listener); err != nil {
if s.running {
glog.Errorf("Worker gRPC server error: %v", err)
}
}
}()
return nil
}
// Stop stops the gRPC server
func (s *WorkerGrpcServer) Stop() error {
if !s.running {
return nil
}
s.running = false
close(s.stopChan)
// Close all worker connections
s.connMutex.Lock()
for _, conn := range s.connections {
conn.cancel()
close(conn.outgoing)
}
s.connections = make(map[string]*WorkerConnection)
s.connMutex.Unlock()
// Stop gRPC server
if s.grpcServer != nil {
s.grpcServer.GracefulStop()
}
// Close listener
if s.listener != nil {
s.listener.Close()
}
glog.Infof("Worker gRPC server stopped")
return nil
}
// WorkerStream handles bidirectional communication with workers
func (s *WorkerGrpcServer) WorkerStream(stream worker_pb.WorkerService_WorkerStreamServer) error {
ctx := stream.Context()
// get client address
address := findClientAddress(ctx)
// Wait for initial registration message
msg, err := stream.Recv()
if err != nil {
return fmt.Errorf("failed to receive registration message: %v", err)
}
registration := msg.GetRegistration()
if registration == nil {
return fmt.Errorf("first message must be registration")
}
registration.Address = address
workerID := registration.WorkerId
if workerID == "" {
return fmt.Errorf("worker ID cannot be empty")
}
glog.Infof("Worker %s connecting from %s", workerID, registration.Address)
// Create worker connection
connCtx, connCancel := context.WithCancel(ctx)
conn := &WorkerConnection{
workerID: workerID,
stream: stream,
lastSeen: time.Now(),
address: registration.Address,
maxConcurrent: registration.MaxConcurrent,
outgoing: make(chan *worker_pb.AdminMessage, 100),
ctx: connCtx,
cancel: connCancel,
}
// Convert capabilities
capabilities := make([]MaintenanceTaskType, len(registration.Capabilities))
for i, cap := range registration.Capabilities {
capabilities[i] = MaintenanceTaskType(cap)
}
conn.capabilities = capabilities
// Register connection
s.connMutex.Lock()
s.connections[workerID] = conn
s.connMutex.Unlock()
// Register worker with maintenance manager
s.registerWorkerWithManager(conn)
// Send registration response
regResponse := &worker_pb.AdminMessage{
Timestamp: time.Now().Unix(),
Message: &worker_pb.AdminMessage_RegistrationResponse{
RegistrationResponse: &worker_pb.RegistrationResponse{
Success: true,
Message: "Worker registered successfully",
},
},
}
select {
case conn.outgoing <- regResponse:
case <-time.After(5 * time.Second):
glog.Errorf("Failed to send registration response to worker %s", workerID)
}
// Start outgoing message handler
go s.handleOutgoingMessages(conn)
// Handle incoming messages
for {
select {
case <-ctx.Done():
glog.Infof("Worker %s connection closed: %v", workerID, ctx.Err())
s.unregisterWorker(workerID)
return nil
case <-connCtx.Done():
glog.Infof("Worker %s connection cancelled", workerID)
s.unregisterWorker(workerID)
return nil
default:
}
msg, err := stream.Recv()
if err != nil {
if err == io.EOF {
glog.Infof("Worker %s disconnected", workerID)
} else {
glog.Errorf("Error receiving from worker %s: %v", workerID, err)
}
s.unregisterWorker(workerID)
return err
}
conn.lastSeen = time.Now()
s.handleWorkerMessage(conn, msg)
}
}
// handleOutgoingMessages sends messages to worker
func (s *WorkerGrpcServer) handleOutgoingMessages(conn *WorkerConnection) {
for {
select {
case <-conn.ctx.Done():
return
case msg, ok := <-conn.outgoing:
if !ok {
return
}
if err := conn.stream.Send(msg); err != nil {
glog.Errorf("Failed to send message to worker %s: %v", conn.workerID, err)
conn.cancel()
return
}
}
}
}
// handleWorkerMessage processes incoming messages from workers
func (s *WorkerGrpcServer) handleWorkerMessage(conn *WorkerConnection, msg *worker_pb.WorkerMessage) {
workerID := conn.workerID
switch m := msg.Message.(type) {
case *worker_pb.WorkerMessage_Heartbeat:
s.handleHeartbeat(conn, m.Heartbeat)
case *worker_pb.WorkerMessage_TaskRequest:
s.handleTaskRequest(conn, m.TaskRequest)
case *worker_pb.WorkerMessage_TaskUpdate:
s.handleTaskUpdate(conn, m.TaskUpdate)
case *worker_pb.WorkerMessage_TaskComplete:
s.handleTaskCompletion(conn, m.TaskComplete)
case *worker_pb.WorkerMessage_Shutdown:
glog.Infof("Worker %s shutting down: %s", workerID, m.Shutdown.Reason)
s.unregisterWorker(workerID)
default:
glog.Warningf("Unknown message type from worker %s", workerID)
}
}
// registerWorkerWithManager registers the worker with the maintenance manager
func (s *WorkerGrpcServer) registerWorkerWithManager(conn *WorkerConnection) {
if s.adminServer.maintenanceManager == nil {
return
}
worker := &MaintenanceWorker{
ID: conn.workerID,
Address: conn.address,
LastHeartbeat: time.Now(),
Status: "active",
Capabilities: conn.capabilities,
MaxConcurrent: int(conn.maxConcurrent),
CurrentLoad: 0,
}
s.adminServer.maintenanceManager.RegisterWorker(worker)
glog.V(1).Infof("Registered worker %s with maintenance manager", conn.workerID)
}
// handleHeartbeat processes heartbeat messages
func (s *WorkerGrpcServer) handleHeartbeat(conn *WorkerConnection, heartbeat *worker_pb.WorkerHeartbeat) {
if s.adminServer.maintenanceManager != nil {
s.adminServer.maintenanceManager.UpdateWorkerHeartbeat(conn.workerID)
}
// Send heartbeat response
response := &worker_pb.AdminMessage{
Timestamp: time.Now().Unix(),
Message: &worker_pb.AdminMessage_HeartbeatResponse{
HeartbeatResponse: &worker_pb.HeartbeatResponse{
Success: true,
Message: "Heartbeat acknowledged",
},
},
}
select {
case conn.outgoing <- response:
case <-time.After(time.Second):
glog.Warningf("Failed to send heartbeat response to worker %s", conn.workerID)
}
}
// handleTaskRequest processes task requests from workers
func (s *WorkerGrpcServer) handleTaskRequest(conn *WorkerConnection, request *worker_pb.TaskRequest) {
if s.adminServer.maintenanceManager == nil {
return
}
// Get next task from maintenance manager
task := s.adminServer.maintenanceManager.GetNextTask(conn.workerID, conn.capabilities)
if task != nil {
// Send task assignment
assignment := &worker_pb.AdminMessage{
Timestamp: time.Now().Unix(),
Message: &worker_pb.AdminMessage_TaskAssignment{
TaskAssignment: &worker_pb.TaskAssignment{
TaskId: task.ID,
TaskType: string(task.Type),
Params: &worker_pb.TaskParams{
VolumeId: task.VolumeID,
Server: task.Server,
Collection: task.Collection,
Parameters: convertTaskParameters(task.Parameters),
},
Priority: int32(task.Priority),
CreatedTime: time.Now().Unix(),
},
},
}
select {
case conn.outgoing <- assignment:
glog.V(2).Infof("Assigned task %s to worker %s", task.ID, conn.workerID)
case <-time.After(time.Second):
glog.Warningf("Failed to send task assignment to worker %s", conn.workerID)
}
}
}
// handleTaskUpdate processes task progress updates
func (s *WorkerGrpcServer) handleTaskUpdate(conn *WorkerConnection, update *worker_pb.TaskUpdate) {
if s.adminServer.maintenanceManager != nil {
s.adminServer.maintenanceManager.UpdateTaskProgress(update.TaskId, float64(update.Progress))
glog.V(3).Infof("Updated task %s progress: %.1f%%", update.TaskId, update.Progress)
}
}
// handleTaskCompletion processes task completion notifications
func (s *WorkerGrpcServer) handleTaskCompletion(conn *WorkerConnection, completion *worker_pb.TaskComplete) {
if s.adminServer.maintenanceManager != nil {
errorMsg := ""
if !completion.Success {
errorMsg = completion.ErrorMessage
}
s.adminServer.maintenanceManager.CompleteTask(completion.TaskId, errorMsg)
if completion.Success {
glog.V(1).Infof("Worker %s completed task %s successfully", conn.workerID, completion.TaskId)
} else {
glog.Errorf("Worker %s failed task %s: %s", conn.workerID, completion.TaskId, completion.ErrorMessage)
}
}
}
// unregisterWorker removes a worker connection
func (s *WorkerGrpcServer) unregisterWorker(workerID string) {
s.connMutex.Lock()
if conn, exists := s.connections[workerID]; exists {
conn.cancel()
close(conn.outgoing)
delete(s.connections, workerID)
}
s.connMutex.Unlock()
glog.V(1).Infof("Unregistered worker %s", workerID)
}
// cleanupRoutine periodically cleans up stale connections
func (s *WorkerGrpcServer) cleanupRoutine() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.stopChan:
return
case <-ticker.C:
s.cleanupStaleConnections()
}
}
}
// cleanupStaleConnections removes connections that haven't been seen recently
func (s *WorkerGrpcServer) cleanupStaleConnections() {
cutoff := time.Now().Add(-2 * time.Minute)
s.connMutex.Lock()
defer s.connMutex.Unlock()
for workerID, conn := range s.connections {
if conn.lastSeen.Before(cutoff) {
glog.Warningf("Cleaning up stale worker connection: %s", workerID)
conn.cancel()
close(conn.outgoing)
delete(s.connections, workerID)
}
}
}
// GetConnectedWorkers returns a list of currently connected workers
func (s *WorkerGrpcServer) GetConnectedWorkers() []string {
s.connMutex.RLock()
defer s.connMutex.RUnlock()
workers := make([]string, 0, len(s.connections))
for workerID := range s.connections {
workers = append(workers, workerID)
}
return workers
}
// convertTaskParameters converts task parameters to protobuf format
func convertTaskParameters(params map[string]interface{}) map[string]string {
result := make(map[string]string)
for key, value := range params {
result[key] = fmt.Sprintf("%v", value)
}
return result
}
func findClientAddress(ctx context.Context) string {
// fmt.Printf("FromContext %+v\n", ctx)
pr, ok := peer.FromContext(ctx)
if !ok {
glog.Error("failed to get peer from ctx")
return ""
}
if pr.Addr == net.Addr(nil) {
glog.Error("failed to get peer address")
return ""
}
return pr.Addr.String()
}
+363
View File
@@ -0,0 +1,363 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
)
// AdminHandlers contains all the HTTP handlers for the admin interface
type AdminHandlers struct {
adminServer *dash.AdminServer
authHandlers *AuthHandlers
clusterHandlers *ClusterHandlers
fileBrowserHandlers *FileBrowserHandlers
userHandlers *UserHandlers
maintenanceHandlers *MaintenanceHandlers
}
// NewAdminHandlers creates a new instance of AdminHandlers
func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
authHandlers := NewAuthHandlers(adminServer)
clusterHandlers := NewClusterHandlers(adminServer)
fileBrowserHandlers := NewFileBrowserHandlers(adminServer)
userHandlers := NewUserHandlers(adminServer)
maintenanceHandlers := NewMaintenanceHandlers(adminServer)
return &AdminHandlers{
adminServer: adminServer,
authHandlers: authHandlers,
clusterHandlers: clusterHandlers,
fileBrowserHandlers: fileBrowserHandlers,
userHandlers: userHandlers,
maintenanceHandlers: maintenanceHandlers,
}
}
// SetupRoutes configures all the routes for the admin interface
func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, password string) {
// Health check (no auth required)
r.GET("/health", h.HealthCheck)
if authRequired {
// Authentication routes (no auth required)
r.GET("/login", h.authHandlers.ShowLogin)
r.POST("/login", h.authHandlers.HandleLogin(username, password))
r.GET("/logout", h.authHandlers.HandleLogout)
// Protected routes group
protected := r.Group("/")
protected.Use(dash.RequireAuth())
// Main admin interface routes
protected.GET("/", h.ShowDashboard)
protected.GET("/admin", h.ShowDashboard)
// Object Store management routes
protected.GET("/object-store/buckets", h.ShowS3Buckets)
protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
// File browser routes
protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
// Cluster management routes
protected.GET("/cluster/masters", h.clusterHandlers.ShowClusterMasters)
protected.GET("/cluster/filers", h.clusterHandlers.ShowClusterFilers)
protected.GET("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers)
protected.GET("/cluster/volumes", h.clusterHandlers.ShowClusterVolumes)
protected.GET("/cluster/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails)
protected.GET("/cluster/collections", h.clusterHandlers.ShowClusterCollections)
// Maintenance system routes
protected.GET("/maintenance", h.maintenanceHandlers.ShowMaintenanceQueue)
protected.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers)
protected.GET("/maintenance/config", h.maintenanceHandlers.ShowMaintenanceConfig)
protected.POST("/maintenance/config", h.maintenanceHandlers.UpdateMaintenanceConfig)
protected.GET("/maintenance/config/:taskType", h.maintenanceHandlers.ShowTaskConfig)
protected.POST("/maintenance/config/:taskType", h.maintenanceHandlers.UpdateTaskConfig)
// API routes for AJAX calls
api := protected.Group("/api")
{
api.GET("/cluster/topology", h.clusterHandlers.GetClusterTopology)
api.GET("/cluster/masters", h.clusterHandlers.GetMasters)
api.GET("/cluster/volumes", h.clusterHandlers.GetVolumeServers)
api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data
api.GET("/config", h.adminServer.GetConfigInfo) // Configuration information
// S3 API routes
s3Api := api.Group("/s3")
{
s3Api.GET("/buckets", h.adminServer.ListBucketsAPI)
s3Api.POST("/buckets", h.adminServer.CreateBucket)
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
}
// User management API routes
usersApi := api.Group("/users")
{
usersApi.GET("", h.userHandlers.GetUsers)
usersApi.POST("", h.userHandlers.CreateUser)
usersApi.GET("/:username", h.userHandlers.GetUserDetails)
usersApi.PUT("/:username", h.userHandlers.UpdateUser)
usersApi.DELETE("/:username", h.userHandlers.DeleteUser)
usersApi.POST("/:username/access-keys", h.userHandlers.CreateAccessKey)
usersApi.DELETE("/:username/access-keys/:accessKeyId", h.userHandlers.DeleteAccessKey)
usersApi.GET("/:username/policies", h.userHandlers.GetUserPolicies)
usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies)
}
// File management API routes
filesApi := api.Group("/files")
{
filesApi.DELETE("/delete", h.fileBrowserHandlers.DeleteFile)
filesApi.DELETE("/delete-multiple", h.fileBrowserHandlers.DeleteMultipleFiles)
filesApi.POST("/create-folder", h.fileBrowserHandlers.CreateFolder)
filesApi.POST("/upload", h.fileBrowserHandlers.UploadFile)
filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile)
filesApi.GET("/view", h.fileBrowserHandlers.ViewFile)
filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties)
}
// Volume management API routes
volumeApi := api.Group("/volumes")
{
volumeApi.POST("/:id/:server/vacuum", h.clusterHandlers.VacuumVolume)
}
// Maintenance API routes
maintenanceApi := api.Group("/maintenance")
{
maintenanceApi.POST("/scan", h.adminServer.TriggerMaintenanceScan)
maintenanceApi.GET("/tasks", h.adminServer.GetMaintenanceTasks)
maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask)
maintenanceApi.POST("/tasks/:id/cancel", h.adminServer.CancelMaintenanceTask)
maintenanceApi.GET("/workers", h.adminServer.GetMaintenanceWorkersAPI)
maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker)
maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats)
maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI)
maintenanceApi.PUT("/config", h.adminServer.UpdateMaintenanceConfigAPI)
}
}
} else {
// No authentication required - all routes are public
r.GET("/", h.ShowDashboard)
r.GET("/admin", h.ShowDashboard)
// Object Store management routes
r.GET("/object-store/buckets", h.ShowS3Buckets)
r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
// File browser routes
r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
// Cluster management routes
r.GET("/cluster/masters", h.clusterHandlers.ShowClusterMasters)
r.GET("/cluster/filers", h.clusterHandlers.ShowClusterFilers)
r.GET("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers)
r.GET("/cluster/volumes", h.clusterHandlers.ShowClusterVolumes)
r.GET("/cluster/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails)
r.GET("/cluster/collections", h.clusterHandlers.ShowClusterCollections)
// Maintenance system routes
r.GET("/maintenance", h.maintenanceHandlers.ShowMaintenanceQueue)
r.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers)
r.GET("/maintenance/config", h.maintenanceHandlers.ShowMaintenanceConfig)
r.POST("/maintenance/config", h.maintenanceHandlers.UpdateMaintenanceConfig)
r.GET("/maintenance/config/:taskType", h.maintenanceHandlers.ShowTaskConfig)
r.POST("/maintenance/config/:taskType", h.maintenanceHandlers.UpdateTaskConfig)
// API routes for AJAX calls
api := r.Group("/api")
{
api.GET("/cluster/topology", h.clusterHandlers.GetClusterTopology)
api.GET("/cluster/masters", h.clusterHandlers.GetMasters)
api.GET("/cluster/volumes", h.clusterHandlers.GetVolumeServers)
api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data
api.GET("/config", h.adminServer.GetConfigInfo) // Configuration information
// S3 API routes
s3Api := api.Group("/s3")
{
s3Api.GET("/buckets", h.adminServer.ListBucketsAPI)
s3Api.POST("/buckets", h.adminServer.CreateBucket)
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
}
// User management API routes
usersApi := api.Group("/users")
{
usersApi.GET("", h.userHandlers.GetUsers)
usersApi.POST("", h.userHandlers.CreateUser)
usersApi.GET("/:username", h.userHandlers.GetUserDetails)
usersApi.PUT("/:username", h.userHandlers.UpdateUser)
usersApi.DELETE("/:username", h.userHandlers.DeleteUser)
usersApi.POST("/:username/access-keys", h.userHandlers.CreateAccessKey)
usersApi.DELETE("/:username/access-keys/:accessKeyId", h.userHandlers.DeleteAccessKey)
usersApi.GET("/:username/policies", h.userHandlers.GetUserPolicies)
usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies)
}
// File management API routes
filesApi := api.Group("/files")
{
filesApi.DELETE("/delete", h.fileBrowserHandlers.DeleteFile)
filesApi.DELETE("/delete-multiple", h.fileBrowserHandlers.DeleteMultipleFiles)
filesApi.POST("/create-folder", h.fileBrowserHandlers.CreateFolder)
filesApi.POST("/upload", h.fileBrowserHandlers.UploadFile)
filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile)
filesApi.GET("/view", h.fileBrowserHandlers.ViewFile)
filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties)
}
// Volume management API routes
volumeApi := api.Group("/volumes")
{
volumeApi.POST("/:id/:server/vacuum", h.clusterHandlers.VacuumVolume)
}
// Maintenance API routes
maintenanceApi := api.Group("/maintenance")
{
maintenanceApi.POST("/scan", h.adminServer.TriggerMaintenanceScan)
maintenanceApi.GET("/tasks", h.adminServer.GetMaintenanceTasks)
maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask)
maintenanceApi.POST("/tasks/:id/cancel", h.adminServer.CancelMaintenanceTask)
maintenanceApi.GET("/workers", h.adminServer.GetMaintenanceWorkersAPI)
maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker)
maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats)
maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI)
maintenanceApi.PUT("/config", h.adminServer.UpdateMaintenanceConfigAPI)
}
}
}
}
// HealthCheck returns the health status of the admin interface
func (h *AdminHandlers) HealthCheck(c *gin.Context) {
c.JSON(200, gin.H{"health": "ok"})
}
// ShowDashboard renders the main admin dashboard
func (h *AdminHandlers) ShowDashboard(c *gin.Context) {
// Get admin data from the server
adminData := h.getAdminData(c)
// Render HTML template
c.Header("Content-Type", "text/html")
adminComponent := app.Admin(adminData)
layoutComponent := layout.Layout(c, adminComponent)
err := layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowS3Buckets renders the Object Store buckets management page
func (h *AdminHandlers) ShowS3Buckets(c *gin.Context) {
// Get Object Store buckets data from the server
s3Data := h.getS3BucketsData(c)
// Render HTML template
c.Header("Content-Type", "text/html")
s3Component := app.S3Buckets(s3Data)
layoutComponent := layout.Layout(c, s3Component)
err := layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowBucketDetails returns detailed information about a specific bucket
func (h *AdminHandlers) ShowBucketDetails(c *gin.Context) {
bucketName := c.Param("bucket")
details, err := h.adminServer.GetBucketDetails(bucketName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()})
return
}
c.JSON(http.StatusOK, details)
}
// getS3BucketsData retrieves Object Store buckets data from the server
func (h *AdminHandlers) getS3BucketsData(c *gin.Context) dash.S3BucketsData {
username := c.GetString("username")
if username == "" {
username = "admin"
}
// Get Object Store buckets
buckets, err := h.adminServer.GetS3Buckets()
if err != nil {
// Return empty data on error
return dash.S3BucketsData{
Username: username,
Buckets: []dash.S3Bucket{},
TotalBuckets: 0,
TotalSize: 0,
LastUpdated: time.Now(),
}
}
// Calculate totals
var totalSize int64
for _, bucket := range buckets {
totalSize += bucket.Size
}
return dash.S3BucketsData{
Username: username,
Buckets: buckets,
TotalBuckets: len(buckets),
TotalSize: totalSize,
LastUpdated: time.Now(),
}
}
// getAdminData retrieves admin data from the server (now uses consolidated method)
func (h *AdminHandlers) getAdminData(c *gin.Context) dash.AdminData {
username := c.GetString("username")
// Use the consolidated GetAdminData method from AdminServer
adminData, err := h.adminServer.GetAdminData(username)
if err != nil {
// Return default data when services are not available
if username == "" {
username = "admin"
}
masterNodes := []dash.MasterNode{
{
Address: "localhost:9333",
IsLeader: true,
},
}
return dash.AdminData{
Username: username,
TotalVolumes: 0,
TotalFiles: 0,
TotalSize: 0,
MasterNodes: masterNodes,
VolumeServers: []dash.VolumeServer{},
FilerNodes: []dash.FilerNode{},
DataCenters: []dash.DataCenter{},
LastUpdated: time.Now(),
}
}
return adminData
}
// Helper functions
+45
View File
@@ -0,0 +1,45 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
)
// AuthHandlers contains authentication-related HTTP handlers
type AuthHandlers struct {
adminServer *dash.AdminServer
}
// NewAuthHandlers creates a new instance of AuthHandlers
func NewAuthHandlers(adminServer *dash.AdminServer) *AuthHandlers {
return &AuthHandlers{
adminServer: adminServer,
}
}
// ShowLogin displays the login page
func (a *AuthHandlers) ShowLogin(c *gin.Context) {
errorMessage := c.Query("error")
// Render login template
c.Header("Content-Type", "text/html")
loginComponent := layout.LoginForm(c, "SeaweedFS Admin", errorMessage)
err := loginComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render login template: " + err.Error()})
return
}
}
// HandleLogin handles login form submission
func (a *AuthHandlers) HandleLogin(username, password string) gin.HandlerFunc {
return a.adminServer.HandleLogin(username, password)
}
// HandleLogout handles user logout
func (a *AuthHandlers) HandleLogout(c *gin.Context) {
a.adminServer.HandleLogout(c)
}
+274
View File
@@ -0,0 +1,274 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
)
// ClusterHandlers contains all the HTTP handlers for cluster management
type ClusterHandlers struct {
adminServer *dash.AdminServer
}
// NewClusterHandlers creates a new instance of ClusterHandlers
func NewClusterHandlers(adminServer *dash.AdminServer) *ClusterHandlers {
return &ClusterHandlers{
adminServer: adminServer,
}
}
// ShowClusterVolumeServers renders the cluster volume servers page
func (h *ClusterHandlers) ShowClusterVolumeServers(c *gin.Context) {
// Get cluster volume servers data
volumeServersData, err := h.adminServer.GetClusterVolumeServers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster volume servers: " + err.Error()})
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
volumeServersData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
volumeServersComponent := app.ClusterVolumeServers(*volumeServersData)
layoutComponent := layout.Layout(c, volumeServersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowClusterVolumes renders the cluster volumes page
func (h *ClusterHandlers) ShowClusterVolumes(c *gin.Context) {
// Get pagination and sorting parameters from query string
page := 1
if p := c.Query("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
pageSize := 100
if ps := c.Query("pageSize"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 1000 {
pageSize = parsed
}
}
sortBy := c.DefaultQuery("sortBy", "id")
sortOrder := c.DefaultQuery("sortOrder", "asc")
collection := c.Query("collection") // Optional collection filter
// Get cluster volumes data
volumesData, err := h.adminServer.GetClusterVolumes(page, pageSize, sortBy, sortOrder, collection)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster volumes: " + err.Error()})
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
volumesData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
volumesComponent := app.ClusterVolumes(*volumesData)
layoutComponent := layout.Layout(c, volumesComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowVolumeDetails renders the volume details page
func (h *ClusterHandlers) ShowVolumeDetails(c *gin.Context) {
volumeIDStr := c.Param("id")
server := c.Param("server")
if volumeIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID is required"})
return
}
if server == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Server is required"})
return
}
volumeID, err := strconv.Atoi(volumeIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid volume ID"})
return
}
// Get volume details
volumeDetails, err := h.adminServer.GetVolumeDetails(volumeID, server)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get volume details: " + err.Error()})
return
}
// Render HTML template
c.Header("Content-Type", "text/html")
volumeDetailsComponent := app.VolumeDetails(*volumeDetails)
layoutComponent := layout.Layout(c, volumeDetailsComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowClusterCollections renders the cluster collections page
func (h *ClusterHandlers) ShowClusterCollections(c *gin.Context) {
// Get cluster collections data
collectionsData, err := h.adminServer.GetClusterCollections()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster collections: " + err.Error()})
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
collectionsData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
collectionsComponent := app.ClusterCollections(*collectionsData)
layoutComponent := layout.Layout(c, collectionsComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowClusterMasters renders the cluster masters page
func (h *ClusterHandlers) ShowClusterMasters(c *gin.Context) {
// Get cluster masters data
mastersData, err := h.adminServer.GetClusterMasters()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster masters: " + err.Error()})
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
mastersData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
mastersComponent := app.ClusterMasters(*mastersData)
layoutComponent := layout.Layout(c, mastersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowClusterFilers renders the cluster filers page
func (h *ClusterHandlers) ShowClusterFilers(c *gin.Context) {
// Get cluster filers data
filersData, err := h.adminServer.GetClusterFilers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster filers: " + err.Error()})
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
filersData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
filersComponent := app.ClusterFilers(*filersData)
layoutComponent := layout.Layout(c, filersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// GetClusterTopology returns the cluster topology as JSON
func (h *ClusterHandlers) GetClusterTopology(c *gin.Context) {
topology, err := h.adminServer.GetClusterTopology()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, topology)
}
// GetMasters returns master node information
func (h *ClusterHandlers) GetMasters(c *gin.Context) {
// Simple master info
c.JSON(http.StatusOK, gin.H{"masters": []gin.H{{"address": "localhost:9333"}}})
}
// GetVolumeServers returns volume server information
func (h *ClusterHandlers) GetVolumeServers(c *gin.Context) {
topology, err := h.adminServer.GetClusterTopology()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"volume_servers": topology.VolumeServers})
}
// VacuumVolume handles volume vacuum requests via API
func (h *ClusterHandlers) VacuumVolume(c *gin.Context) {
volumeIDStr := c.Param("id")
server := c.Param("server")
if volumeIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID is required"})
return
}
volumeID, err := strconv.Atoi(volumeIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid volume ID"})
return
}
// Perform vacuum operation
err = h.adminServer.VacuumVolume(volumeID, server)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to vacuum volume: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Volume vacuum started successfully",
"volume_id": volumeID,
"server": server,
})
}
@@ -0,0 +1,941 @@
package handlers
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
type FileBrowserHandlers struct {
adminServer *dash.AdminServer
}
func NewFileBrowserHandlers(adminServer *dash.AdminServer) *FileBrowserHandlers {
return &FileBrowserHandlers{
adminServer: adminServer,
}
}
// ShowFileBrowser renders the file browser page
func (h *FileBrowserHandlers) ShowFileBrowser(c *gin.Context) {
// Get path from query parameter, default to root
path := c.DefaultQuery("path", "/")
// Get file browser data
browserData, err := h.adminServer.GetFileBrowser(path)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file browser data: " + err.Error()})
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
browserData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
browserComponent := app.FileBrowser(*browserData)
layoutComponent := layout.Layout(c, browserComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// DeleteFile handles file deletion API requests
func (h *FileBrowserHandlers) DeleteFile(c *gin.Context) {
var request struct {
Path string `json:"path" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Delete file via filer
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
Directory: filepath.Dir(request.Path),
Name: filepath.Base(request.Path),
IsDeleteData: true,
IsRecursive: true,
IgnoreRecursiveError: false,
})
return err
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"})
}
// DeleteMultipleFiles handles multiple file deletion API requests
func (h *FileBrowserHandlers) DeleteMultipleFiles(c *gin.Context) {
var request struct {
Paths []string `json:"paths" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
if len(request.Paths) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No paths provided"})
return
}
var deletedCount int
var failedCount int
var errors []string
// Delete each file/folder
for _, path := range request.Paths {
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
Directory: filepath.Dir(path),
Name: filepath.Base(path),
IsDeleteData: true,
IsRecursive: true,
IgnoreRecursiveError: false,
})
return err
})
if err != nil {
failedCount++
errors = append(errors, fmt.Sprintf("%s: %v", path, err))
} else {
deletedCount++
}
}
// Prepare response
response := map[string]interface{}{
"deleted": deletedCount,
"failed": failedCount,
"total": len(request.Paths),
}
if len(errors) > 0 {
response["errors"] = errors
}
if deletedCount > 0 {
if failedCount == 0 {
response["message"] = fmt.Sprintf("Successfully deleted %d item(s)", deletedCount)
} else {
response["message"] = fmt.Sprintf("Deleted %d item(s), failed to delete %d item(s)", deletedCount, failedCount)
}
c.JSON(http.StatusOK, response)
} else {
response["message"] = "Failed to delete all selected items"
c.JSON(http.StatusInternalServerError, response)
}
}
// CreateFolder handles folder creation requests
func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) {
var request struct {
Path string `json:"path" binding:"required"`
FolderName string `json:"folder_name" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Clean and validate folder name
folderName := strings.TrimSpace(request.FolderName)
if folderName == "" || strings.Contains(folderName, "/") || strings.Contains(folderName, "\\") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder name"})
return
}
// Create full path for new folder
fullPath := filepath.Join(request.Path, folderName)
if !strings.HasPrefix(fullPath, "/") {
fullPath = "/" + fullPath
}
// Create folder via filer
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
Directory: filepath.Dir(fullPath),
Entry: &filer_pb.Entry{
Name: filepath.Base(fullPath),
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
FileMode: uint32(0755 | (1 << 31)), // Directory mode
Uid: filer_pb.OS_UID,
Gid: filer_pb.OS_GID,
Crtime: time.Now().Unix(),
Mtime: time.Now().Unix(),
TtlSec: 0,
},
},
})
return err
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create folder: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Folder created successfully"})
}
// UploadFile handles file upload requests
func (h *FileBrowserHandlers) UploadFile(c *gin.Context) {
// Get the current path
currentPath := c.PostForm("path")
if currentPath == "" {
currentPath = "/"
}
// Parse multipart form
err := c.Request.ParseMultipartForm(100 << 20) // 100MB max memory
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse multipart form: " + err.Error()})
return
}
// Get uploaded files (supports multiple files)
files := c.Request.MultipartForm.File["files"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No files uploaded"})
return
}
var uploadResults []map[string]interface{}
var failedUploads []string
// Process each uploaded file
for _, fileHeader := range files {
// Validate file name
fileName := fileHeader.Filename
if fileName == "" {
failedUploads = append(failedUploads, "invalid filename")
continue
}
// Create full path for the file
fullPath := filepath.Join(currentPath, fileName)
if !strings.HasPrefix(fullPath, "/") {
fullPath = "/" + fullPath
}
// Open the file
file, err := fileHeader.Open()
if err != nil {
failedUploads = append(failedUploads, fmt.Sprintf("%s: %v", fileName, err))
continue
}
// Upload file to filer
err = h.uploadFileToFiler(fullPath, fileHeader)
file.Close()
if err != nil {
failedUploads = append(failedUploads, fmt.Sprintf("%s: %v", fileName, err))
} else {
uploadResults = append(uploadResults, map[string]interface{}{
"name": fileName,
"size": fileHeader.Size,
"path": fullPath,
})
}
}
// Prepare response
response := map[string]interface{}{
"uploaded": len(uploadResults),
"failed": len(failedUploads),
"files": uploadResults,
}
if len(failedUploads) > 0 {
response["errors"] = failedUploads
}
if len(uploadResults) > 0 {
if len(failedUploads) == 0 {
response["message"] = fmt.Sprintf("Successfully uploaded %d file(s)", len(uploadResults))
} else {
response["message"] = fmt.Sprintf("Uploaded %d file(s), %d failed", len(uploadResults), len(failedUploads))
}
c.JSON(http.StatusOK, response)
} else {
response["message"] = "All file uploads failed"
c.JSON(http.StatusInternalServerError, response)
}
}
// uploadFileToFiler uploads a file directly to the filer using multipart form data
func (h *FileBrowserHandlers) uploadFileToFiler(filePath string, fileHeader *multipart.FileHeader) error {
// Get filer address from admin server
filerAddress := h.adminServer.GetFilerAddress()
if filerAddress == "" {
return fmt.Errorf("filer address not configured")
}
// Validate and sanitize the filer address
if err := h.validateFilerAddress(filerAddress); err != nil {
return fmt.Errorf("invalid filer address: %v", err)
}
// Validate and sanitize the file path
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return fmt.Errorf("invalid file path: %v", err)
}
// Open the file
file, err := fileHeader.Open()
if err != nil {
return fmt.Errorf("failed to open file: %v", err)
}
defer file.Close()
// Create multipart form data
var body bytes.Buffer
writer := multipart.NewWriter(&body)
// Create form file field
part, err := writer.CreateFormFile("file", fileHeader.Filename)
if err != nil {
return fmt.Errorf("failed to create form file: %v", err)
}
// Copy file content to form
_, err = io.Copy(part, file)
if err != nil {
return fmt.Errorf("failed to copy file content: %v", err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close multipart writer: %v", err)
}
// Create the upload URL with validated components
uploadURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
// Create HTTP request
req, err := http.NewRequest("POST", uploadURL, &body)
if err != nil {
return fmt.Errorf("failed to create request: %v", err)
}
// Set content type with boundary
req.Header.Set("Content-Type", writer.FormDataContentType())
// Send request
client := &http.Client{Timeout: 60 * time.Second} // Increased timeout for larger files
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to upload file: %v", err)
}
defer resp.Body.Close()
// Check response
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
responseBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(responseBody))
}
return nil
}
// validateFilerAddress validates that the filer address is safe to use
func (h *FileBrowserHandlers) validateFilerAddress(address string) error {
if address == "" {
return fmt.Errorf("filer address cannot be empty")
}
// Parse the address to validate it's a proper host:port format
host, port, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("invalid address format: %v", err)
}
// Validate host is not empty
if host == "" {
return fmt.Errorf("host cannot be empty")
}
// Validate port is numeric and in valid range
if port == "" {
return fmt.Errorf("port cannot be empty")
}
portNum, err := strconv.Atoi(port)
if err != nil {
return fmt.Errorf("invalid port number: %v", err)
}
if portNum < 1 || portNum > 65535 {
return fmt.Errorf("port number must be between 1 and 65535")
}
// Additional security: prevent private network access unless explicitly allowed
// This helps prevent SSRF attacks to internal services
ip := net.ParseIP(host)
if ip != nil {
// Check for localhost, private networks, and other dangerous addresses
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() {
// Only allow if it's the configured filer (trusted)
// In production, you might want to be more restrictive
glog.V(2).Infof("Allowing access to private/local address: %s (configured filer)", address)
}
}
return nil
}
// validateAndCleanFilePath validates and cleans the file path to prevent path traversal
func (h *FileBrowserHandlers) validateAndCleanFilePath(filePath string) (string, error) {
if filePath == "" {
return "", fmt.Errorf("file path cannot be empty")
}
// Clean the path to remove any .. or . components
cleanPath := filepath.Clean(filePath)
// Ensure the path starts with /
if !strings.HasPrefix(cleanPath, "/") {
cleanPath = "/" + cleanPath
}
// Prevent path traversal attacks
if strings.Contains(cleanPath, "..") {
return "", fmt.Errorf("path traversal not allowed")
}
// Additional validation: ensure path doesn't contain dangerous characters
if strings.ContainsAny(cleanPath, "\x00\r\n") {
return "", fmt.Errorf("path contains invalid characters")
}
return cleanPath, nil
}
// DownloadFile handles file download requests
func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
filePath := c.Query("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "File path is required"})
return
}
// Get filer address
filerAddress := h.adminServer.GetFilerAddress()
if filerAddress == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Filer address not configured"})
return
}
// Validate and sanitize the file path
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file path: " + err.Error()})
return
}
// Create the download URL
downloadURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
// Set headers for file download
fileName := filepath.Base(cleanFilePath)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Header("Content-Type", "application/octet-stream")
// Proxy the request to filer
c.Redirect(http.StatusFound, downloadURL)
}
// ViewFile handles file viewing requests (for text files, images, etc.)
func (h *FileBrowserHandlers) ViewFile(c *gin.Context) {
filePath := c.Query("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "File path is required"})
return
}
// Get file metadata first
var fileEntry dash.FileEntry
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
Directory: filepath.Dir(filePath),
Name: filepath.Base(filePath),
})
if err != nil {
return err
}
entry := resp.Entry
if entry == nil {
return fmt.Errorf("file not found")
}
// Convert to FileEntry
var modTime time.Time
if entry.Attributes != nil && entry.Attributes.Mtime > 0 {
modTime = time.Unix(entry.Attributes.Mtime, 0)
}
var size int64
if entry.Attributes != nil {
size = int64(entry.Attributes.FileSize)
}
// Determine MIME type with comprehensive extension support
mime := h.determineMimeType(entry.Name)
fileEntry = dash.FileEntry{
Name: entry.Name,
FullPath: filePath,
IsDirectory: entry.IsDirectory,
Size: size,
ModTime: modTime,
Mime: mime,
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file metadata: " + err.Error()})
return
}
// Check if file is viewable as text
var content string
var viewable bool
var reason string
// First check if it's a known text type or if we should check content
isKnownTextType := strings.HasPrefix(fileEntry.Mime, "text/") ||
fileEntry.Mime == "application/json" ||
fileEntry.Mime == "application/javascript" ||
fileEntry.Mime == "application/xml"
// For unknown types, check if it might be text by content
if !isKnownTextType && fileEntry.Mime == "application/octet-stream" {
isKnownTextType = h.isLikelyTextFile(filePath, 512)
if isKnownTextType {
// Update MIME type for better display
fileEntry.Mime = "text/plain"
}
}
if isKnownTextType {
// Limit text file size for viewing (max 1MB)
if fileEntry.Size > 1024*1024 {
viewable = false
reason = "File too large for viewing (>1MB)"
} else {
// Get file content from filer
filerAddress := h.adminServer.GetFilerAddress()
if filerAddress != "" {
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err == nil {
fileURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(fileURL)
if err == nil && resp.StatusCode == http.StatusOK {
defer resp.Body.Close()
contentBytes, err := io.ReadAll(resp.Body)
if err == nil {
content = string(contentBytes)
viewable = true
} else {
viewable = false
reason = "Failed to read file content"
}
} else {
viewable = false
reason = "Failed to fetch file from filer"
}
} else {
viewable = false
reason = "Invalid file path"
}
} else {
viewable = false
reason = "Filer address not configured"
}
}
} else {
// Not a text file, but might be viewable as image or PDF
if strings.HasPrefix(fileEntry.Mime, "image/") || fileEntry.Mime == "application/pdf" {
viewable = true
} else {
viewable = false
reason = "File type not supported for viewing"
}
}
c.JSON(http.StatusOK, gin.H{
"file": fileEntry,
"content": content,
"viewable": viewable,
"reason": reason,
})
}
// GetFileProperties handles file properties requests
func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) {
filePath := c.Query("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "File path is required"})
return
}
// Get detailed file information from filer
var properties map[string]interface{}
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
Directory: filepath.Dir(filePath),
Name: filepath.Base(filePath),
})
if err != nil {
return err
}
entry := resp.Entry
if entry == nil {
return fmt.Errorf("file not found")
}
properties = make(map[string]interface{})
properties["name"] = entry.Name
properties["full_path"] = filePath
properties["is_directory"] = entry.IsDirectory
if entry.Attributes != nil {
properties["size"] = entry.Attributes.FileSize
properties["size_formatted"] = h.formatBytes(int64(entry.Attributes.FileSize))
if entry.Attributes.Mtime > 0 {
modTime := time.Unix(entry.Attributes.Mtime, 0)
properties["modified_time"] = modTime.Format("2006-01-02 15:04:05")
properties["modified_timestamp"] = entry.Attributes.Mtime
}
if entry.Attributes.Crtime > 0 {
createTime := time.Unix(entry.Attributes.Crtime, 0)
properties["created_time"] = createTime.Format("2006-01-02 15:04:05")
properties["created_timestamp"] = entry.Attributes.Crtime
}
properties["file_mode"] = fmt.Sprintf("%o", entry.Attributes.FileMode)
properties["file_mode_formatted"] = h.formatFileMode(entry.Attributes.FileMode)
properties["uid"] = entry.Attributes.Uid
properties["gid"] = entry.Attributes.Gid
properties["ttl_seconds"] = entry.Attributes.TtlSec
if entry.Attributes.TtlSec > 0 {
properties["ttl_formatted"] = fmt.Sprintf("%d seconds", entry.Attributes.TtlSec)
}
}
// Get extended attributes
if entry.Extended != nil {
extended := make(map[string]string)
for key, value := range entry.Extended {
extended[key] = string(value)
}
properties["extended"] = extended
}
// Get chunk information for files
if !entry.IsDirectory && len(entry.Chunks) > 0 {
chunks := make([]map[string]interface{}, 0, len(entry.Chunks))
for _, chunk := range entry.Chunks {
chunkInfo := map[string]interface{}{
"file_id": chunk.FileId,
"offset": chunk.Offset,
"size": chunk.Size,
"modified_ts": chunk.ModifiedTsNs,
"e_tag": chunk.ETag,
"source_fid": chunk.SourceFileId,
}
chunks = append(chunks, chunkInfo)
}
properties["chunks"] = chunks
properties["chunk_count"] = len(entry.Chunks)
}
// Determine MIME type
if !entry.IsDirectory {
mime := h.determineMimeType(entry.Name)
properties["mime_type"] = mime
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file properties: " + err.Error()})
return
}
c.JSON(http.StatusOK, properties)
}
// Helper function to format bytes
func (h *FileBrowserHandlers) formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// Helper function to format file mode
func (h *FileBrowserHandlers) formatFileMode(mode uint32) string {
// Convert to octal and format as rwx permissions
perm := mode & 0777
return fmt.Sprintf("%03o", perm)
}
// Helper function to determine MIME type from filename
func (h *FileBrowserHandlers) determineMimeType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
// Text files
switch ext {
case ".txt", ".log", ".cfg", ".conf", ".ini", ".properties":
return "text/plain"
case ".md", ".markdown":
return "text/markdown"
case ".html", ".htm":
return "text/html"
case ".css":
return "text/css"
case ".js", ".mjs":
return "application/javascript"
case ".ts":
return "text/typescript"
case ".json":
return "application/json"
case ".xml":
return "application/xml"
case ".yaml", ".yml":
return "text/yaml"
case ".csv":
return "text/csv"
case ".sql":
return "text/sql"
case ".sh", ".bash", ".zsh", ".fish":
return "text/x-shellscript"
case ".py":
return "text/x-python"
case ".go":
return "text/x-go"
case ".java":
return "text/x-java"
case ".c":
return "text/x-c"
case ".cpp", ".cc", ".cxx", ".c++":
return "text/x-c++"
case ".h", ".hpp":
return "text/x-c-header"
case ".php":
return "text/x-php"
case ".rb":
return "text/x-ruby"
case ".pl":
return "text/x-perl"
case ".rs":
return "text/x-rust"
case ".swift":
return "text/x-swift"
case ".kt":
return "text/x-kotlin"
case ".scala":
return "text/x-scala"
case ".dockerfile":
return "text/x-dockerfile"
case ".gitignore", ".gitattributes":
return "text/plain"
case ".env":
return "text/plain"
// Image files
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".bmp":
return "image/bmp"
case ".webp":
return "image/webp"
case ".svg":
return "image/svg+xml"
case ".ico":
return "image/x-icon"
// Document files
case ".pdf":
return "application/pdf"
case ".doc":
return "application/msword"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xls":
return "application/vnd.ms-excel"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".ppt":
return "application/vnd.ms-powerpoint"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
// Archive files
case ".zip":
return "application/zip"
case ".tar":
return "application/x-tar"
case ".gz":
return "application/gzip"
case ".bz2":
return "application/x-bzip2"
case ".7z":
return "application/x-7z-compressed"
case ".rar":
return "application/x-rar-compressed"
// Video files
case ".mp4":
return "video/mp4"
case ".avi":
return "video/x-msvideo"
case ".mov":
return "video/quicktime"
case ".wmv":
return "video/x-ms-wmv"
case ".flv":
return "video/x-flv"
case ".webm":
return "video/webm"
// Audio files
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/wav"
case ".flac":
return "audio/flac"
case ".aac":
return "audio/aac"
case ".ogg":
return "audio/ogg"
default:
// For files without extension or unknown extensions,
// we'll check if they might be text files by content
return "application/octet-stream"
}
}
// Helper function to check if a file is likely a text file by checking content
func (h *FileBrowserHandlers) isLikelyTextFile(filePath string, maxCheckSize int64) bool {
filerAddress := h.adminServer.GetFilerAddress()
if filerAddress == "" {
return false
}
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return false
}
fileURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(fileURL)
if err != nil || resp.StatusCode != http.StatusOK {
return false
}
defer resp.Body.Close()
// Read first few bytes to check if it's text
buffer := make([]byte, min(maxCheckSize, 512))
n, err := resp.Body.Read(buffer)
if err != nil && err != io.EOF {
return false
}
if n == 0 {
return true // Empty file can be considered text
}
// Check if content is printable text
return h.isPrintableText(buffer[:n])
}
// Helper function to check if content is printable text
func (h *FileBrowserHandlers) isPrintableText(data []byte) bool {
if len(data) == 0 {
return true
}
// Count printable characters
printable := 0
for _, b := range data {
if b >= 32 && b <= 126 || b == 9 || b == 10 || b == 13 {
// Printable ASCII, tab, newline, carriage return
printable++
} else if b >= 128 {
// Potential UTF-8 character
printable++
}
}
// If more than 95% of characters are printable, consider it text
return float64(printable)/float64(len(data)) > 0.95
}
// Helper function for min
func min(a, b int64) int64 {
if a < b {
return a
}
return b
}
+388
View File
@@ -0,0 +1,388 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/components"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// MaintenanceHandlers handles maintenance-related HTTP requests
type MaintenanceHandlers struct {
adminServer *dash.AdminServer
}
// NewMaintenanceHandlers creates a new instance of MaintenanceHandlers
func NewMaintenanceHandlers(adminServer *dash.AdminServer) *MaintenanceHandlers {
return &MaintenanceHandlers{
adminServer: adminServer,
}
}
// ShowMaintenanceQueue displays the maintenance queue page
func (h *MaintenanceHandlers) ShowMaintenanceQueue(c *gin.Context) {
data, err := h.getMaintenanceQueueData()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Render HTML template
c.Header("Content-Type", "text/html")
maintenanceComponent := app.MaintenanceQueue(data)
layoutComponent := layout.Layout(c, maintenanceComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowMaintenanceWorkers displays the maintenance workers page
func (h *MaintenanceHandlers) ShowMaintenanceWorkers(c *gin.Context) {
workersData, err := h.adminServer.GetMaintenanceWorkersData()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Render HTML template
c.Header("Content-Type", "text/html")
workersComponent := app.MaintenanceWorkers(workersData)
layoutComponent := layout.Layout(c, workersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowMaintenanceConfig displays the maintenance configuration page
func (h *MaintenanceHandlers) ShowMaintenanceConfig(c *gin.Context) {
config, err := h.getMaintenanceConfig()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Render HTML template
c.Header("Content-Type", "text/html")
configComponent := app.MaintenanceConfig(config)
layoutComponent := layout.Layout(c, configComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// ShowTaskConfig displays the configuration page for a specific task type
func (h *MaintenanceHandlers) ShowTaskConfig(c *gin.Context) {
taskTypeName := c.Param("taskType")
// Get the task type
taskType := maintenance.GetMaintenanceTaskType(taskTypeName)
if taskType == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Task type not found"})
return
}
// Get the UI provider for this task type
uiRegistry := tasks.GetGlobalUIRegistry()
typesRegistry := tasks.GetGlobalTypesRegistry()
var provider types.TaskUIProvider
for workerTaskType := range typesRegistry.GetAllDetectors() {
if string(workerTaskType) == string(taskType) {
provider = uiRegistry.GetProvider(workerTaskType)
break
}
}
if provider == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
return
}
// Try to get templ UI provider first
templUIProvider := getTemplUIProvider(taskType)
var configSections []components.ConfigSectionData
if templUIProvider != nil {
// Use the new templ-based UI provider
currentConfig := templUIProvider.GetCurrentConfig()
sections, err := templUIProvider.RenderConfigSections(currentConfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration sections: " + err.Error()})
return
}
configSections = sections
} else {
// Fallback to basic configuration for providers that haven't been migrated yet
configSections = []components.ConfigSectionData{
{
Title: "Configuration Settings",
Icon: "fas fa-cogs",
Description: "Configure task detection and scheduling parameters",
Fields: []interface{}{
components.CheckboxFieldData{
FormFieldData: components.FormFieldData{
Name: "enabled",
Label: "Enable Task",
Description: "Whether this task type should be enabled",
},
Checked: true,
},
components.NumberFieldData{
FormFieldData: components.FormFieldData{
Name: "max_concurrent",
Label: "Max Concurrent Tasks",
Description: "Maximum number of concurrent tasks",
Required: true,
},
Value: 2,
Step: "1",
Min: floatPtr(1),
},
components.DurationFieldData{
FormFieldData: components.FormFieldData{
Name: "scan_interval",
Label: "Scan Interval",
Description: "How often to scan for tasks",
Required: true,
},
Value: "30m",
},
},
},
}
}
// Create task configuration data using templ components
configData := &app.TaskConfigTemplData{
TaskType: taskType,
TaskName: provider.GetDisplayName(),
TaskIcon: provider.GetIcon(),
Description: provider.GetDescription(),
ConfigSections: configSections,
}
// Render HTML template using templ components
c.Header("Content-Type", "text/html")
taskConfigComponent := app.TaskConfigTempl(configData)
layoutComponent := layout.Layout(c, taskConfigComponent)
err := layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// UpdateTaskConfig updates configuration for a specific task type
func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) {
taskTypeName := c.Param("taskType")
// Get the task type
taskType := maintenance.GetMaintenanceTaskType(taskTypeName)
if taskType == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Task type not found"})
return
}
// Try to get templ UI provider first
templUIProvider := getTemplUIProvider(taskType)
// Parse form data
err := c.Request.ParseForm()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse form data: " + err.Error()})
return
}
// Convert form data to map
formData := make(map[string][]string)
for key, values := range c.Request.PostForm {
formData[key] = values
}
var config interface{}
if templUIProvider != nil {
// Use the new templ-based UI provider
config, err = templUIProvider.ParseConfigForm(formData)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
return
}
// Apply configuration using templ provider
err = templUIProvider.ApplyConfig(config)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
} else {
// Fallback to old UI provider for tasks that haven't been migrated yet
uiRegistry := tasks.GetGlobalUIRegistry()
typesRegistry := tasks.GetGlobalTypesRegistry()
var provider types.TaskUIProvider
for workerTaskType := range typesRegistry.GetAllDetectors() {
if string(workerTaskType) == string(taskType) {
provider = uiRegistry.GetProvider(workerTaskType)
break
}
}
if provider == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
return
}
// Parse configuration from form using old provider
config, err = provider.ParseConfigForm(formData)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
return
}
// Apply configuration using old provider
err = provider.ApplyConfig(config)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
// Redirect back to task configuration page
c.Redirect(http.StatusSeeOther, "/maintenance/config/"+taskTypeName)
}
// UpdateMaintenanceConfig updates maintenance configuration from form
func (h *MaintenanceHandlers) UpdateMaintenanceConfig(c *gin.Context) {
var config maintenance.MaintenanceConfig
if err := c.ShouldBind(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.updateMaintenanceConfig(&config)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Redirect(http.StatusSeeOther, "/maintenance/config")
}
// Helper methods that delegate to AdminServer
func (h *MaintenanceHandlers) getMaintenanceQueueData() (*maintenance.MaintenanceQueueData, error) {
tasks, err := h.getMaintenanceTasks()
if err != nil {
return nil, err
}
workers, err := h.getMaintenanceWorkers()
if err != nil {
return nil, err
}
stats, err := h.getMaintenanceQueueStats()
if err != nil {
return nil, err
}
return &maintenance.MaintenanceQueueData{
Tasks: tasks,
Workers: workers,
Stats: stats,
LastUpdated: time.Now(),
}, nil
}
func (h *MaintenanceHandlers) getMaintenanceQueueStats() (*maintenance.QueueStats, error) {
// This would integrate with the maintenance queue to get real statistics
// For now, return mock data
return &maintenance.QueueStats{
PendingTasks: 5,
RunningTasks: 2,
CompletedToday: 15,
FailedToday: 1,
TotalTasks: 23,
}, nil
}
func (h *MaintenanceHandlers) getMaintenanceTasks() ([]*maintenance.MaintenanceTask, error) {
// This would integrate with the maintenance queue to get real tasks
// For now, return mock data
return []*maintenance.MaintenanceTask{}, nil
}
func (h *MaintenanceHandlers) getMaintenanceWorkers() ([]*maintenance.MaintenanceWorker, error) {
// This would integrate with the maintenance system to get real workers
// For now, return mock data
return []*maintenance.MaintenanceWorker{}, nil
}
func (h *MaintenanceHandlers) getMaintenanceConfig() (*maintenance.MaintenanceConfigData, error) {
// Delegate to AdminServer's real persistence method
return h.adminServer.GetMaintenanceConfigData()
}
func (h *MaintenanceHandlers) updateMaintenanceConfig(config *maintenance.MaintenanceConfig) error {
// Delegate to AdminServer's real persistence method
return h.adminServer.UpdateMaintenanceConfigData(config)
}
// floatPtr is a helper function to create float64 pointers
func floatPtr(f float64) *float64 {
return &f
}
// Global templ UI registry
var globalTemplUIRegistry *types.UITemplRegistry
// initTemplUIRegistry initializes the global templ UI registry
func initTemplUIRegistry() {
if globalTemplUIRegistry == nil {
globalTemplUIRegistry = types.NewUITemplRegistry()
// Register vacuum templ UI provider using shared instances
vacuumDetector, vacuumScheduler := vacuum.GetSharedInstances()
vacuum.RegisterUITempl(globalTemplUIRegistry, vacuumDetector, vacuumScheduler)
// Register erasure coding templ UI provider using shared instances
erasureCodingDetector, erasureCodingScheduler := erasure_coding.GetSharedInstances()
erasure_coding.RegisterUITempl(globalTemplUIRegistry, erasureCodingDetector, erasureCodingScheduler)
// Register balance templ UI provider using shared instances
balanceDetector, balanceScheduler := balance.GetSharedInstances()
balance.RegisterUITempl(globalTemplUIRegistry, balanceDetector, balanceScheduler)
}
}
// getTemplUIProvider gets the templ UI provider for a task type
func getTemplUIProvider(taskType maintenance.MaintenanceTaskType) types.TaskUITemplProvider {
initTemplUIRegistry()
// Convert maintenance task type to worker task type
typesRegistry := tasks.GetGlobalTypesRegistry()
for workerTaskType := range typesRegistry.GetAllDetectors() {
if string(workerTaskType) == string(taskType) {
return globalTemplUIRegistry.GetProvider(workerTaskType)
}
}
return nil
}
+255
View File
@@ -0,0 +1,255 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// UserHandlers contains all the HTTP handlers for user management
type UserHandlers struct {
adminServer *dash.AdminServer
}
// NewUserHandlers creates a new instance of UserHandlers
func NewUserHandlers(adminServer *dash.AdminServer) *UserHandlers {
return &UserHandlers{
adminServer: adminServer,
}
}
// ShowObjectStoreUsers renders the object store users management page
func (h *UserHandlers) ShowObjectStoreUsers(c *gin.Context) {
// Get object store users data from the server
usersData := h.getObjectStoreUsersData(c)
// Render HTML template
c.Header("Content-Type", "text/html")
usersComponent := app.ObjectStoreUsers(usersData)
layoutComponent := layout.Layout(c, usersComponent)
err := layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
return
}
}
// GetUsers returns the list of users as JSON
func (h *UserHandlers) GetUsers(c *gin.Context) {
users, err := h.adminServer.GetObjectStoreUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get users: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
// CreateUser handles user creation
func (h *UserHandlers) CreateUser(c *gin.Context) {
var req dash.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Validate required fields
if req.Username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
user, err := h.adminServer.CreateObjectStoreUser(req)
if err != nil {
glog.Errorf("Failed to create user %s: %v", req.Username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "User created successfully",
"user": user,
})
}
// UpdateUser handles user updates
func (h *UserHandlers) UpdateUser(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
var req dash.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
user, err := h.adminServer.UpdateObjectStoreUser(username, req)
if err != nil {
glog.Errorf("Failed to update user %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "User updated successfully",
"user": user,
})
}
// DeleteUser handles user deletion
func (h *UserHandlers) DeleteUser(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
err := h.adminServer.DeleteObjectStoreUser(username)
if err != nil {
glog.Errorf("Failed to delete user %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "User deleted successfully",
})
}
// GetUserDetails returns detailed information about a specific user
func (h *UserHandlers) GetUserDetails(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
user, err := h.adminServer.GetObjectStoreUserDetails(username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found: " + err.Error()})
return
}
c.JSON(http.StatusOK, user)
}
// CreateAccessKey creates a new access key for a user
func (h *UserHandlers) CreateAccessKey(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
accessKey, err := h.adminServer.CreateAccessKey(username)
if err != nil {
glog.Errorf("Failed to create access key for user %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create access key: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Access key created successfully",
"access_key": accessKey,
})
}
// DeleteAccessKey deletes an access key for a user
func (h *UserHandlers) DeleteAccessKey(c *gin.Context) {
username := c.Param("username")
accessKeyId := c.Param("accessKeyId")
if username == "" || accessKeyId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username and access key ID are required"})
return
}
err := h.adminServer.DeleteAccessKey(username, accessKeyId)
if err != nil {
glog.Errorf("Failed to delete access key %s for user %s: %v", accessKeyId, username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete access key: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Access key deleted successfully",
})
}
// GetUserPolicies returns the policies for a user
func (h *UserHandlers) GetUserPolicies(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
policies, err := h.adminServer.GetUserPolicies(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user policies: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"policies": policies})
}
// UpdateUserPolicies updates the policies for a user
func (h *UserHandlers) UpdateUserPolicies(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
var req dash.UpdateUserPoliciesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
err := h.adminServer.UpdateUserPolicies(username, req.Actions)
if err != nil {
glog.Errorf("Failed to update policies for user %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user policies: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "User policies updated successfully",
})
}
// getObjectStoreUsersData retrieves object store users data from the server
func (h *UserHandlers) getObjectStoreUsersData(c *gin.Context) dash.ObjectStoreUsersData {
username := c.GetString("username")
if username == "" {
username = "admin"
}
// Get object store users
users, err := h.adminServer.GetObjectStoreUsers()
if err != nil {
glog.Errorf("Failed to get object store users: %v", err)
// Return empty data on error
return dash.ObjectStoreUsersData{
Username: username,
Users: []dash.ObjectStoreUser{},
TotalUsers: 0,
LastUpdated: time.Now(),
}
}
return dash.ObjectStoreUsersData{
Username: username,
Users: users,
TotalUsers: len(users),
LastUpdated: time.Now(),
}
}
@@ -0,0 +1,409 @@
package maintenance
import (
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// MaintenanceIntegration bridges the task system with existing maintenance
type MaintenanceIntegration struct {
taskRegistry *types.TaskRegistry
uiRegistry *types.UIRegistry
// Bridge to existing system
maintenanceQueue *MaintenanceQueue
maintenancePolicy *MaintenancePolicy
// Type conversion maps
taskTypeMap map[types.TaskType]MaintenanceTaskType
revTaskTypeMap map[MaintenanceTaskType]types.TaskType
priorityMap map[types.TaskPriority]MaintenanceTaskPriority
revPriorityMap map[MaintenanceTaskPriority]types.TaskPriority
}
// NewMaintenanceIntegration creates the integration bridge
func NewMaintenanceIntegration(queue *MaintenanceQueue, policy *MaintenancePolicy) *MaintenanceIntegration {
integration := &MaintenanceIntegration{
taskRegistry: tasks.GetGlobalTypesRegistry(), // Use global types registry with auto-registered tasks
uiRegistry: tasks.GetGlobalUIRegistry(), // Use global UI registry with auto-registered UI providers
maintenanceQueue: queue,
maintenancePolicy: policy,
}
// Initialize type conversion maps
integration.initializeTypeMaps()
// Register all tasks
integration.registerAllTasks()
return integration
}
// initializeTypeMaps creates the type conversion maps for dynamic conversion
func (s *MaintenanceIntegration) initializeTypeMaps() {
// Initialize empty maps
s.taskTypeMap = make(map[types.TaskType]MaintenanceTaskType)
s.revTaskTypeMap = make(map[MaintenanceTaskType]types.TaskType)
// Build task type mappings dynamically from registered tasks after registration
// This will be called from registerAllTasks() after all tasks are registered
// Priority mappings (these are static and don't depend on registered tasks)
s.priorityMap = map[types.TaskPriority]MaintenanceTaskPriority{
types.TaskPriorityLow: PriorityLow,
types.TaskPriorityNormal: PriorityNormal,
types.TaskPriorityHigh: PriorityHigh,
}
// Reverse priority mappings
s.revPriorityMap = map[MaintenanceTaskPriority]types.TaskPriority{
PriorityLow: types.TaskPriorityLow,
PriorityNormal: types.TaskPriorityNormal,
PriorityHigh: types.TaskPriorityHigh,
PriorityCritical: types.TaskPriorityHigh, // Map critical to high
}
}
// buildTaskTypeMappings dynamically builds task type mappings from registered tasks
func (s *MaintenanceIntegration) buildTaskTypeMappings() {
// Clear existing mappings
s.taskTypeMap = make(map[types.TaskType]MaintenanceTaskType)
s.revTaskTypeMap = make(map[MaintenanceTaskType]types.TaskType)
// Build mappings from registered detectors
for workerTaskType := range s.taskRegistry.GetAllDetectors() {
// Convert types.TaskType to MaintenanceTaskType by string conversion
maintenanceTaskType := MaintenanceTaskType(string(workerTaskType))
s.taskTypeMap[workerTaskType] = maintenanceTaskType
s.revTaskTypeMap[maintenanceTaskType] = workerTaskType
glog.V(3).Infof("Dynamically mapped task type: %s <-> %s", workerTaskType, maintenanceTaskType)
}
glog.V(2).Infof("Built %d dynamic task type mappings", len(s.taskTypeMap))
}
// registerAllTasks registers all available tasks
func (s *MaintenanceIntegration) registerAllTasks() {
// Tasks are already auto-registered via import statements
// No manual registration needed
// Build dynamic type mappings from registered tasks
s.buildTaskTypeMappings()
// Configure tasks from policy
s.configureTasksFromPolicy()
registeredTaskTypes := make([]string, 0, len(s.taskTypeMap))
for _, maintenanceTaskType := range s.taskTypeMap {
registeredTaskTypes = append(registeredTaskTypes, string(maintenanceTaskType))
}
glog.V(1).Infof("Registered tasks: %v", registeredTaskTypes)
}
// configureTasksFromPolicy dynamically configures all registered tasks based on the maintenance policy
func (s *MaintenanceIntegration) configureTasksFromPolicy() {
if s.maintenancePolicy == nil {
return
}
// Configure all registered detectors and schedulers dynamically using policy configuration
configuredCount := 0
// Get all registered task types from the registry
for taskType, detector := range s.taskRegistry.GetAllDetectors() {
// Configure detector using policy-based configuration
s.configureDetectorFromPolicy(taskType, detector)
configuredCount++
}
for taskType, scheduler := range s.taskRegistry.GetAllSchedulers() {
// Configure scheduler using policy-based configuration
s.configureSchedulerFromPolicy(taskType, scheduler)
}
glog.V(1).Infof("Dynamically configured %d task types from maintenance policy", configuredCount)
}
// configureDetectorFromPolicy configures a detector using policy-based configuration
func (s *MaintenanceIntegration) configureDetectorFromPolicy(taskType types.TaskType, detector types.TaskDetector) {
// Try to configure using PolicyConfigurableDetector interface if supported
if configurableDetector, ok := detector.(types.PolicyConfigurableDetector); ok {
configurableDetector.ConfigureFromPolicy(s.maintenancePolicy)
glog.V(2).Infof("Configured detector %s using policy interface", taskType)
return
}
// Apply basic configuration that all detectors should support
if basicDetector, ok := detector.(interface{ SetEnabled(bool) }); ok {
// Convert task system type to maintenance task type for policy lookup
maintenanceTaskType, exists := s.taskTypeMap[taskType]
if exists {
enabled := s.maintenancePolicy.IsTaskEnabled(maintenanceTaskType)
basicDetector.SetEnabled(enabled)
glog.V(3).Infof("Set enabled=%v for detector %s", enabled, taskType)
}
}
// For detectors that don't implement PolicyConfigurableDetector interface,
// they should be updated to implement it for full policy-based configuration
glog.V(2).Infof("Detector %s should implement PolicyConfigurableDetector interface for full policy support", taskType)
}
// configureSchedulerFromPolicy configures a scheduler using policy-based configuration
func (s *MaintenanceIntegration) configureSchedulerFromPolicy(taskType types.TaskType, scheduler types.TaskScheduler) {
// Try to configure using PolicyConfigurableScheduler interface if supported
if configurableScheduler, ok := scheduler.(types.PolicyConfigurableScheduler); ok {
configurableScheduler.ConfigureFromPolicy(s.maintenancePolicy)
glog.V(2).Infof("Configured scheduler %s using policy interface", taskType)
return
}
// Apply basic configuration that all schedulers should support
maintenanceTaskType, exists := s.taskTypeMap[taskType]
if !exists {
glog.V(3).Infof("No maintenance task type mapping for %s, skipping configuration", taskType)
return
}
// Set enabled status if scheduler supports it
if enableableScheduler, ok := scheduler.(interface{ SetEnabled(bool) }); ok {
enabled := s.maintenancePolicy.IsTaskEnabled(maintenanceTaskType)
enableableScheduler.SetEnabled(enabled)
glog.V(3).Infof("Set enabled=%v for scheduler %s", enabled, taskType)
}
// Set max concurrent if scheduler supports it
if concurrentScheduler, ok := scheduler.(interface{ SetMaxConcurrent(int) }); ok {
maxConcurrent := s.maintenancePolicy.GetMaxConcurrent(maintenanceTaskType)
if maxConcurrent > 0 {
concurrentScheduler.SetMaxConcurrent(maxConcurrent)
glog.V(3).Infof("Set max concurrent=%d for scheduler %s", maxConcurrent, taskType)
}
}
// For schedulers that don't implement PolicyConfigurableScheduler interface,
// they should be updated to implement it for full policy-based configuration
glog.V(2).Infof("Scheduler %s should implement PolicyConfigurableScheduler interface for full policy support", taskType)
}
// ScanWithTaskDetectors performs a scan using the task system
func (s *MaintenanceIntegration) ScanWithTaskDetectors(volumeMetrics []*types.VolumeHealthMetrics) ([]*TaskDetectionResult, error) {
var allResults []*TaskDetectionResult
// Create cluster info
clusterInfo := &types.ClusterInfo{
TotalVolumes: len(volumeMetrics),
LastUpdated: time.Now(),
}
// Run detection for each registered task type
for taskType, detector := range s.taskRegistry.GetAllDetectors() {
if !detector.IsEnabled() {
continue
}
glog.V(2).Infof("Running detection for task type: %s", taskType)
results, err := detector.ScanForTasks(volumeMetrics, clusterInfo)
if err != nil {
glog.Errorf("Failed to scan for %s tasks: %v", taskType, err)
continue
}
// Convert results to existing system format
for _, result := range results {
existingResult := s.convertToExistingFormat(result)
if existingResult != nil {
allResults = append(allResults, existingResult)
}
}
glog.V(2).Infof("Found %d %s tasks", len(results), taskType)
}
return allResults, nil
}
// convertToExistingFormat converts task results to existing system format using dynamic mapping
func (s *MaintenanceIntegration) convertToExistingFormat(result *types.TaskDetectionResult) *TaskDetectionResult {
// Convert types using mapping tables
existingType, exists := s.taskTypeMap[result.TaskType]
if !exists {
glog.Warningf("Unknown task type %s, skipping conversion", result.TaskType)
// Return nil to indicate conversion failed - caller should handle this
return nil
}
existingPriority, exists := s.priorityMap[result.Priority]
if !exists {
glog.Warningf("Unknown priority %d, defaulting to normal", result.Priority)
existingPriority = PriorityNormal
}
return &TaskDetectionResult{
TaskType: existingType,
VolumeID: result.VolumeID,
Server: result.Server,
Collection: result.Collection,
Priority: existingPriority,
Reason: result.Reason,
Parameters: result.Parameters,
ScheduleAt: result.ScheduleAt,
}
}
// CanScheduleWithTaskSchedulers determines if a task can be scheduled using task schedulers with dynamic type conversion
func (s *MaintenanceIntegration) CanScheduleWithTaskSchedulers(task *MaintenanceTask, runningTasks []*MaintenanceTask, availableWorkers []*MaintenanceWorker) bool {
// Convert existing types to task types using mapping
taskType, exists := s.revTaskTypeMap[task.Type]
if !exists {
glog.V(2).Infof("Unknown task type %s for scheduling, falling back to existing logic", task.Type)
return false // Fallback to existing logic for unknown types
}
// Convert task objects
taskObject := s.convertTaskToTaskSystem(task)
if taskObject == nil {
glog.V(2).Infof("Failed to convert task %s for scheduling", task.ID)
return false
}
runningTaskObjects := s.convertTasksToTaskSystem(runningTasks)
workerObjects := s.convertWorkersToTaskSystem(availableWorkers)
// Get the appropriate scheduler
scheduler := s.taskRegistry.GetScheduler(taskType)
if scheduler == nil {
glog.V(2).Infof("No scheduler found for task type %s", taskType)
return false
}
return scheduler.CanScheduleNow(taskObject, runningTaskObjects, workerObjects)
}
// convertTaskToTaskSystem converts existing task to task system format using dynamic mapping
func (s *MaintenanceIntegration) convertTaskToTaskSystem(task *MaintenanceTask) *types.Task {
// Convert task type using mapping
taskType, exists := s.revTaskTypeMap[task.Type]
if !exists {
glog.Errorf("Unknown task type %s in conversion, cannot convert task", task.Type)
// Return nil to indicate conversion failed
return nil
}
// Convert priority using mapping
priority, exists := s.revPriorityMap[task.Priority]
if !exists {
glog.Warningf("Unknown priority %d in conversion, defaulting to normal", task.Priority)
priority = types.TaskPriorityNormal
}
return &types.Task{
ID: task.ID,
Type: taskType,
Priority: priority,
VolumeID: task.VolumeID,
Server: task.Server,
Collection: task.Collection,
Parameters: task.Parameters,
CreatedAt: task.CreatedAt,
}
}
// convertTasksToTaskSystem converts multiple tasks
func (s *MaintenanceIntegration) convertTasksToTaskSystem(tasks []*MaintenanceTask) []*types.Task {
var result []*types.Task
for _, task := range tasks {
converted := s.convertTaskToTaskSystem(task)
if converted != nil {
result = append(result, converted)
}
}
return result
}
// convertWorkersToTaskSystem converts workers to task system format using dynamic mapping
func (s *MaintenanceIntegration) convertWorkersToTaskSystem(workers []*MaintenanceWorker) []*types.Worker {
var result []*types.Worker
for _, worker := range workers {
capabilities := make([]types.TaskType, 0, len(worker.Capabilities))
for _, cap := range worker.Capabilities {
// Convert capability using mapping
taskType, exists := s.revTaskTypeMap[cap]
if exists {
capabilities = append(capabilities, taskType)
} else {
glog.V(3).Infof("Unknown capability %s for worker %s, skipping", cap, worker.ID)
}
}
result = append(result, &types.Worker{
ID: worker.ID,
Address: worker.Address,
Capabilities: capabilities,
MaxConcurrent: worker.MaxConcurrent,
CurrentLoad: worker.CurrentLoad,
})
}
return result
}
// GetTaskScheduler returns the scheduler for a task type using dynamic mapping
func (s *MaintenanceIntegration) GetTaskScheduler(taskType MaintenanceTaskType) types.TaskScheduler {
// Convert task type using mapping
taskSystemType, exists := s.revTaskTypeMap[taskType]
if !exists {
glog.V(3).Infof("Unknown task type %s for scheduler", taskType)
return nil
}
return s.taskRegistry.GetScheduler(taskSystemType)
}
// GetUIProvider returns the UI provider for a task type using dynamic mapping
func (s *MaintenanceIntegration) GetUIProvider(taskType MaintenanceTaskType) types.TaskUIProvider {
// Convert task type using mapping
taskSystemType, exists := s.revTaskTypeMap[taskType]
if !exists {
glog.V(3).Infof("Unknown task type %s for UI provider", taskType)
return nil
}
return s.uiRegistry.GetProvider(taskSystemType)
}
// GetAllTaskStats returns stats for all registered tasks
func (s *MaintenanceIntegration) GetAllTaskStats() []*types.TaskStats {
var stats []*types.TaskStats
for taskType, detector := range s.taskRegistry.GetAllDetectors() {
uiProvider := s.uiRegistry.GetProvider(taskType)
if uiProvider == nil {
continue
}
stat := &types.TaskStats{
TaskType: taskType,
DisplayName: uiProvider.GetDisplayName(),
Enabled: detector.IsEnabled(),
LastScan: time.Now().Add(-detector.ScanInterval()),
NextScan: time.Now().Add(detector.ScanInterval()),
ScanInterval: detector.ScanInterval(),
MaxConcurrent: s.taskRegistry.GetScheduler(taskType).GetMaxConcurrent(),
// Would need to get these from actual queue/stats
PendingTasks: 0,
RunningTasks: 0,
CompletedToday: 0,
FailedToday: 0,
}
stats = append(stats, stat)
}
return stats
}
@@ -0,0 +1,407 @@
package maintenance
import (
"fmt"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// MaintenanceManager coordinates the maintenance system
type MaintenanceManager struct {
config *MaintenanceConfig
scanner *MaintenanceScanner
queue *MaintenanceQueue
adminClient AdminClient
running bool
stopChan chan struct{}
// Error handling and backoff
errorCount int
lastError error
lastErrorTime time.Time
backoffDelay time.Duration
mutex sync.RWMutex
}
// NewMaintenanceManager creates a new maintenance manager
func NewMaintenanceManager(adminClient AdminClient, config *MaintenanceConfig) *MaintenanceManager {
if config == nil {
config = DefaultMaintenanceConfig()
}
queue := NewMaintenanceQueue(config.Policy)
scanner := NewMaintenanceScanner(adminClient, config.Policy, queue)
return &MaintenanceManager{
config: config,
scanner: scanner,
queue: queue,
adminClient: adminClient,
stopChan: make(chan struct{}),
backoffDelay: time.Second, // Start with 1 second backoff
}
}
// Start begins the maintenance manager
func (mm *MaintenanceManager) Start() error {
if !mm.config.Enabled {
glog.V(1).Infof("Maintenance system is disabled")
return nil
}
// Validate configuration durations to prevent ticker panics
if err := mm.validateConfig(); err != nil {
return fmt.Errorf("invalid maintenance configuration: %v", err)
}
mm.running = true
// Start background processes
go mm.scanLoop()
go mm.cleanupLoop()
glog.Infof("Maintenance manager started with scan interval %ds", mm.config.ScanIntervalSeconds)
return nil
}
// validateConfig validates the maintenance configuration durations
func (mm *MaintenanceManager) validateConfig() error {
if mm.config.ScanIntervalSeconds <= 0 {
glog.Warningf("Invalid scan interval %ds, using default 30m", mm.config.ScanIntervalSeconds)
mm.config.ScanIntervalSeconds = 30 * 60 // 30 minutes in seconds
}
if mm.config.CleanupIntervalSeconds <= 0 {
glog.Warningf("Invalid cleanup interval %ds, using default 24h", mm.config.CleanupIntervalSeconds)
mm.config.CleanupIntervalSeconds = 24 * 60 * 60 // 24 hours in seconds
}
if mm.config.WorkerTimeoutSeconds <= 0 {
glog.Warningf("Invalid worker timeout %ds, using default 5m", mm.config.WorkerTimeoutSeconds)
mm.config.WorkerTimeoutSeconds = 5 * 60 // 5 minutes in seconds
}
if mm.config.TaskTimeoutSeconds <= 0 {
glog.Warningf("Invalid task timeout %ds, using default 2h", mm.config.TaskTimeoutSeconds)
mm.config.TaskTimeoutSeconds = 2 * 60 * 60 // 2 hours in seconds
}
if mm.config.RetryDelaySeconds <= 0 {
glog.Warningf("Invalid retry delay %ds, using default 15m", mm.config.RetryDelaySeconds)
mm.config.RetryDelaySeconds = 15 * 60 // 15 minutes in seconds
}
if mm.config.TaskRetentionSeconds <= 0 {
glog.Warningf("Invalid task retention %ds, using default 168h", mm.config.TaskRetentionSeconds)
mm.config.TaskRetentionSeconds = 7 * 24 * 60 * 60 // 7 days in seconds
}
return nil
}
// IsRunning returns whether the maintenance manager is currently running
func (mm *MaintenanceManager) IsRunning() bool {
return mm.running
}
// Stop terminates the maintenance manager
func (mm *MaintenanceManager) Stop() {
mm.running = false
close(mm.stopChan)
glog.Infof("Maintenance manager stopped")
}
// scanLoop periodically scans for maintenance tasks with adaptive timing
func (mm *MaintenanceManager) scanLoop() {
scanInterval := time.Duration(mm.config.ScanIntervalSeconds) * time.Second
ticker := time.NewTicker(scanInterval)
defer ticker.Stop()
for mm.running {
select {
case <-mm.stopChan:
return
case <-ticker.C:
glog.V(1).Infof("Performing maintenance scan every %v", scanInterval)
mm.performScan()
// Adjust ticker interval based on error state
mm.mutex.RLock()
currentInterval := scanInterval
if mm.errorCount > 0 {
// Use backoff delay when there are errors
currentInterval = mm.backoffDelay
if currentInterval > scanInterval {
// Don't make it longer than the configured interval * 10
maxInterval := scanInterval * 10
if currentInterval > maxInterval {
currentInterval = maxInterval
}
}
}
mm.mutex.RUnlock()
// Reset ticker with new interval if needed
if currentInterval != scanInterval {
ticker.Stop()
ticker = time.NewTicker(currentInterval)
}
}
}
}
// cleanupLoop periodically cleans up old tasks and stale workers
func (mm *MaintenanceManager) cleanupLoop() {
cleanupInterval := time.Duration(mm.config.CleanupIntervalSeconds) * time.Second
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for mm.running {
select {
case <-mm.stopChan:
return
case <-ticker.C:
mm.performCleanup()
}
}
}
// performScan executes a maintenance scan with error handling and backoff
func (mm *MaintenanceManager) performScan() {
mm.mutex.Lock()
defer mm.mutex.Unlock()
glog.V(2).Infof("Starting maintenance scan")
results, err := mm.scanner.ScanForMaintenanceTasks()
if err != nil {
mm.handleScanError(err)
return
}
// Scan succeeded, reset error tracking
mm.resetErrorTracking()
if len(results) > 0 {
mm.queue.AddTasksFromResults(results)
glog.V(1).Infof("Maintenance scan completed: added %d tasks", len(results))
} else {
glog.V(2).Infof("Maintenance scan completed: no tasks needed")
}
}
// handleScanError handles scan errors with exponential backoff and reduced logging
func (mm *MaintenanceManager) handleScanError(err error) {
now := time.Now()
mm.errorCount++
mm.lastError = err
mm.lastErrorTime = now
// Use exponential backoff with jitter
if mm.errorCount > 1 {
mm.backoffDelay = mm.backoffDelay * 2
if mm.backoffDelay > 5*time.Minute {
mm.backoffDelay = 5 * time.Minute // Cap at 5 minutes
}
}
// Reduce log frequency based on error count and time
shouldLog := false
if mm.errorCount <= 3 {
// Log first 3 errors immediately
shouldLog = true
} else if mm.errorCount <= 10 && mm.errorCount%3 == 0 {
// Log every 3rd error for errors 4-10
shouldLog = true
} else if mm.errorCount%10 == 0 {
// Log every 10th error after that
shouldLog = true
}
if shouldLog {
// Check if it's a connection error to provide better messaging
if isConnectionError(err) {
if mm.errorCount == 1 {
glog.Errorf("Maintenance scan failed: %v (will retry with backoff)", err)
} else {
glog.Errorf("Maintenance scan still failing after %d attempts: %v (backoff: %v)",
mm.errorCount, err, mm.backoffDelay)
}
} else {
glog.Errorf("Maintenance scan failed: %v", err)
}
} else {
// Use debug level for suppressed errors
glog.V(3).Infof("Maintenance scan failed (error #%d, suppressed): %v", mm.errorCount, err)
}
}
// resetErrorTracking resets error tracking when scan succeeds
func (mm *MaintenanceManager) resetErrorTracking() {
if mm.errorCount > 0 {
glog.V(1).Infof("Maintenance scan recovered after %d failed attempts", mm.errorCount)
mm.errorCount = 0
mm.lastError = nil
mm.backoffDelay = time.Second // Reset to initial delay
}
}
// isConnectionError checks if the error is a connection-related error
func isConnectionError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "connection error") ||
strings.Contains(errStr, "dial tcp") ||
strings.Contains(errStr, "connection timeout") ||
strings.Contains(errStr, "no route to host") ||
strings.Contains(errStr, "network unreachable")
}
// performCleanup cleans up old tasks and stale workers
func (mm *MaintenanceManager) performCleanup() {
glog.V(2).Infof("Starting maintenance cleanup")
taskRetention := time.Duration(mm.config.TaskRetentionSeconds) * time.Second
workerTimeout := time.Duration(mm.config.WorkerTimeoutSeconds) * time.Second
removedTasks := mm.queue.CleanupOldTasks(taskRetention)
removedWorkers := mm.queue.RemoveStaleWorkers(workerTimeout)
if removedTasks > 0 || removedWorkers > 0 {
glog.V(1).Infof("Cleanup completed: removed %d old tasks and %d stale workers", removedTasks, removedWorkers)
}
}
// GetQueue returns the maintenance queue
func (mm *MaintenanceManager) GetQueue() *MaintenanceQueue {
return mm.queue
}
// GetConfig returns the maintenance configuration
func (mm *MaintenanceManager) GetConfig() *MaintenanceConfig {
return mm.config
}
// GetStats returns maintenance statistics
func (mm *MaintenanceManager) GetStats() *MaintenanceStats {
stats := mm.queue.GetStats()
mm.mutex.RLock()
defer mm.mutex.RUnlock()
stats.LastScanTime = time.Now() // Would need to track this properly
// Calculate next scan time based on current error state
scanInterval := time.Duration(mm.config.ScanIntervalSeconds) * time.Second
nextScanInterval := scanInterval
if mm.errorCount > 0 {
nextScanInterval = mm.backoffDelay
maxInterval := scanInterval * 10
if nextScanInterval > maxInterval {
nextScanInterval = maxInterval
}
}
stats.NextScanTime = time.Now().Add(nextScanInterval)
return stats
}
// GetErrorState returns the current error state for monitoring
func (mm *MaintenanceManager) GetErrorState() (errorCount int, lastError error, backoffDelay time.Duration) {
mm.mutex.RLock()
defer mm.mutex.RUnlock()
return mm.errorCount, mm.lastError, mm.backoffDelay
}
// GetTasks returns tasks with filtering
func (mm *MaintenanceManager) GetTasks(status MaintenanceTaskStatus, taskType MaintenanceTaskType, limit int) []*MaintenanceTask {
return mm.queue.GetTasks(status, taskType, limit)
}
// GetWorkers returns all registered workers
func (mm *MaintenanceManager) GetWorkers() []*MaintenanceWorker {
return mm.queue.GetWorkers()
}
// TriggerScan manually triggers a maintenance scan
func (mm *MaintenanceManager) TriggerScan() error {
if !mm.running {
return fmt.Errorf("maintenance manager is not running")
}
go mm.performScan()
return nil
}
// UpdateConfig updates the maintenance configuration
func (mm *MaintenanceManager) UpdateConfig(config *MaintenanceConfig) error {
if config == nil {
return fmt.Errorf("config cannot be nil")
}
mm.config = config
mm.queue.policy = config.Policy
mm.scanner.policy = config.Policy
glog.V(1).Infof("Maintenance configuration updated")
return nil
}
// CancelTask cancels a pending task
func (mm *MaintenanceManager) CancelTask(taskID string) error {
mm.queue.mutex.Lock()
defer mm.queue.mutex.Unlock()
task, exists := mm.queue.tasks[taskID]
if !exists {
return fmt.Errorf("task %s not found", taskID)
}
if task.Status == TaskStatusPending {
task.Status = TaskStatusCancelled
task.CompletedAt = &[]time.Time{time.Now()}[0]
// Remove from pending tasks
for i, pendingTask := range mm.queue.pendingTasks {
if pendingTask.ID == taskID {
mm.queue.pendingTasks = append(mm.queue.pendingTasks[:i], mm.queue.pendingTasks[i+1:]...)
break
}
}
glog.V(2).Infof("Cancelled task %s", taskID)
return nil
}
return fmt.Errorf("task %s cannot be cancelled (status: %s)", taskID, task.Status)
}
// RegisterWorker registers a new worker
func (mm *MaintenanceManager) RegisterWorker(worker *MaintenanceWorker) {
mm.queue.RegisterWorker(worker)
}
// GetNextTask returns the next task for a worker
func (mm *MaintenanceManager) GetNextTask(workerID string, capabilities []MaintenanceTaskType) *MaintenanceTask {
return mm.queue.GetNextTask(workerID, capabilities)
}
// CompleteTask marks a task as completed
func (mm *MaintenanceManager) CompleteTask(taskID string, error string) {
mm.queue.CompleteTask(taskID, error)
}
// UpdateTaskProgress updates task progress
func (mm *MaintenanceManager) UpdateTaskProgress(taskID string, progress float64) {
mm.queue.UpdateTaskProgress(taskID, progress)
}
// UpdateWorkerHeartbeat updates worker heartbeat
func (mm *MaintenanceManager) UpdateWorkerHeartbeat(workerID string) {
mm.queue.UpdateWorkerHeartbeat(workerID)
}
@@ -0,0 +1,140 @@
package maintenance
import (
"errors"
"testing"
"time"
)
func TestMaintenanceManager_ErrorHandling(t *testing.T) {
config := DefaultMaintenanceConfig()
config.ScanIntervalSeconds = 1 // Short interval for testing (1 second)
manager := NewMaintenanceManager(nil, config)
// Test initial state
if manager.errorCount != 0 {
t.Errorf("Expected initial error count to be 0, got %d", manager.errorCount)
}
if manager.backoffDelay != time.Second {
t.Errorf("Expected initial backoff delay to be 1s, got %v", manager.backoffDelay)
}
// Test error handling
err := errors.New("dial tcp [::1]:19333: connect: connection refused")
manager.handleScanError(err)
if manager.errorCount != 1 {
t.Errorf("Expected error count to be 1, got %d", manager.errorCount)
}
if manager.lastError != err {
t.Errorf("Expected last error to be set")
}
// Test exponential backoff
initialDelay := manager.backoffDelay
manager.handleScanError(err)
if manager.backoffDelay != initialDelay*2 {
t.Errorf("Expected backoff delay to double, got %v", manager.backoffDelay)
}
if manager.errorCount != 2 {
t.Errorf("Expected error count to be 2, got %d", manager.errorCount)
}
// Test backoff cap
for i := 0; i < 10; i++ {
manager.handleScanError(err)
}
if manager.backoffDelay > 5*time.Minute {
t.Errorf("Expected backoff delay to be capped at 5 minutes, got %v", manager.backoffDelay)
}
// Test error reset
manager.resetErrorTracking()
if manager.errorCount != 0 {
t.Errorf("Expected error count to be reset to 0, got %d", manager.errorCount)
}
if manager.backoffDelay != time.Second {
t.Errorf("Expected backoff delay to be reset to 1s, got %v", manager.backoffDelay)
}
if manager.lastError != nil {
t.Errorf("Expected last error to be reset to nil")
}
}
func TestIsConnectionError(t *testing.T) {
tests := []struct {
err error
expected bool
}{
{nil, false},
{errors.New("connection refused"), true},
{errors.New("dial tcp [::1]:19333: connect: connection refused"), true},
{errors.New("connection error: desc = \"transport: Error while dialing\""), true},
{errors.New("connection timeout"), true},
{errors.New("no route to host"), true},
{errors.New("network unreachable"), true},
{errors.New("some other error"), false},
{errors.New("invalid argument"), false},
}
for _, test := range tests {
result := isConnectionError(test.err)
if result != test.expected {
t.Errorf("For error %v, expected %v, got %v", test.err, test.expected, result)
}
}
}
func TestMaintenanceManager_GetErrorState(t *testing.T) {
config := DefaultMaintenanceConfig()
manager := NewMaintenanceManager(nil, config)
// Test initial state
errorCount, lastError, backoffDelay := manager.GetErrorState()
if errorCount != 0 || lastError != nil || backoffDelay != time.Second {
t.Errorf("Expected initial state to be clean")
}
// Add some errors
err := errors.New("test error")
manager.handleScanError(err)
manager.handleScanError(err)
errorCount, lastError, backoffDelay = manager.GetErrorState()
if errorCount != 2 || lastError != err || backoffDelay != 2*time.Second {
t.Errorf("Expected error state to be tracked correctly: count=%d, err=%v, delay=%v",
errorCount, lastError, backoffDelay)
}
}
func TestMaintenanceManager_LogThrottling(t *testing.T) {
config := DefaultMaintenanceConfig()
manager := NewMaintenanceManager(nil, config)
// This is a basic test to ensure the error handling doesn't panic
// In practice, you'd want to capture log output to verify throttling
err := errors.New("test error")
// Generate many errors to test throttling
for i := 0; i < 25; i++ {
manager.handleScanError(err)
}
// Should not panic and should have capped backoff
if manager.backoffDelay > 5*time.Minute {
t.Errorf("Expected backoff to be capped at 5 minutes")
}
if manager.errorCount != 25 {
t.Errorf("Expected error count to be 25, got %d", manager.errorCount)
}
}
+500
View File
@@ -0,0 +1,500 @@
package maintenance
import (
"sort"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// NewMaintenanceQueue creates a new maintenance queue
func NewMaintenanceQueue(policy *MaintenancePolicy) *MaintenanceQueue {
queue := &MaintenanceQueue{
tasks: make(map[string]*MaintenanceTask),
workers: make(map[string]*MaintenanceWorker),
pendingTasks: make([]*MaintenanceTask, 0),
policy: policy,
}
return queue
}
// SetIntegration sets the integration reference
func (mq *MaintenanceQueue) SetIntegration(integration *MaintenanceIntegration) {
mq.integration = integration
glog.V(1).Infof("Maintenance queue configured with integration")
}
// AddTask adds a new maintenance task to the queue
func (mq *MaintenanceQueue) AddTask(task *MaintenanceTask) {
mq.mutex.Lock()
defer mq.mutex.Unlock()
task.ID = generateTaskID()
task.Status = TaskStatusPending
task.CreatedAt = time.Now()
task.MaxRetries = 3 // Default retry count
mq.tasks[task.ID] = task
mq.pendingTasks = append(mq.pendingTasks, task)
// Sort pending tasks by priority and schedule time
sort.Slice(mq.pendingTasks, func(i, j int) bool {
if mq.pendingTasks[i].Priority != mq.pendingTasks[j].Priority {
return mq.pendingTasks[i].Priority > mq.pendingTasks[j].Priority
}
return mq.pendingTasks[i].ScheduledAt.Before(mq.pendingTasks[j].ScheduledAt)
})
glog.V(2).Infof("Added maintenance task %s: %s for volume %d", task.ID, task.Type, task.VolumeID)
}
// AddTasksFromResults converts detection results to tasks and adds them to the queue
func (mq *MaintenanceQueue) AddTasksFromResults(results []*TaskDetectionResult) {
for _, result := range results {
task := &MaintenanceTask{
Type: result.TaskType,
Priority: result.Priority,
VolumeID: result.VolumeID,
Server: result.Server,
Collection: result.Collection,
Parameters: result.Parameters,
Reason: result.Reason,
ScheduledAt: result.ScheduleAt,
}
mq.AddTask(task)
}
}
// GetNextTask returns the next available task for a worker
func (mq *MaintenanceQueue) GetNextTask(workerID string, capabilities []MaintenanceTaskType) *MaintenanceTask {
mq.mutex.Lock()
defer mq.mutex.Unlock()
worker, exists := mq.workers[workerID]
if !exists {
return nil
}
// Check if worker has capacity
if worker.CurrentLoad >= worker.MaxConcurrent {
return nil
}
now := time.Now()
// Find the next suitable task
for i, task := range mq.pendingTasks {
// Check if it's time to execute the task
if task.ScheduledAt.After(now) {
continue
}
// Check if worker can handle this task type
if !mq.workerCanHandle(task.Type, capabilities) {
continue
}
// Check scheduling logic - use simplified system if available, otherwise fallback
if !mq.canScheduleTaskNow(task) {
continue
}
// Assign task to worker
task.Status = TaskStatusAssigned
task.WorkerID = workerID
startTime := now
task.StartedAt = &startTime
// Remove from pending tasks
mq.pendingTasks = append(mq.pendingTasks[:i], mq.pendingTasks[i+1:]...)
// Update worker
worker.CurrentTask = task
worker.CurrentLoad++
worker.Status = "busy"
glog.V(2).Infof("Assigned task %s to worker %s", task.ID, workerID)
return task
}
return nil
}
// CompleteTask marks a task as completed
func (mq *MaintenanceQueue) CompleteTask(taskID string, error string) {
mq.mutex.Lock()
defer mq.mutex.Unlock()
task, exists := mq.tasks[taskID]
if !exists {
return
}
completedTime := time.Now()
task.CompletedAt = &completedTime
if error != "" {
task.Status = TaskStatusFailed
task.Error = error
// Check if task should be retried
if task.RetryCount < task.MaxRetries {
task.RetryCount++
task.Status = TaskStatusPending
task.WorkerID = ""
task.StartedAt = nil
task.CompletedAt = nil
task.Error = ""
task.ScheduledAt = time.Now().Add(15 * time.Minute) // Retry delay
mq.pendingTasks = append(mq.pendingTasks, task)
glog.V(2).Infof("Retrying task %s (attempt %d/%d)", taskID, task.RetryCount, task.MaxRetries)
} else {
glog.Errorf("Task %s failed permanently after %d retries: %s", taskID, task.MaxRetries, error)
}
} else {
task.Status = TaskStatusCompleted
task.Progress = 100
glog.V(2).Infof("Task %s completed successfully", taskID)
}
// Update worker
if task.WorkerID != "" {
if worker, exists := mq.workers[task.WorkerID]; exists {
worker.CurrentTask = nil
worker.CurrentLoad--
if worker.CurrentLoad == 0 {
worker.Status = "active"
}
}
}
}
// UpdateTaskProgress updates the progress of a running task
func (mq *MaintenanceQueue) UpdateTaskProgress(taskID string, progress float64) {
mq.mutex.RLock()
defer mq.mutex.RUnlock()
if task, exists := mq.tasks[taskID]; exists {
task.Progress = progress
task.Status = TaskStatusInProgress
}
}
// RegisterWorker registers a new worker
func (mq *MaintenanceQueue) RegisterWorker(worker *MaintenanceWorker) {
mq.mutex.Lock()
defer mq.mutex.Unlock()
worker.LastHeartbeat = time.Now()
worker.Status = "active"
worker.CurrentLoad = 0
mq.workers[worker.ID] = worker
glog.V(1).Infof("Registered maintenance worker %s at %s", worker.ID, worker.Address)
}
// UpdateWorkerHeartbeat updates worker heartbeat
func (mq *MaintenanceQueue) UpdateWorkerHeartbeat(workerID string) {
mq.mutex.Lock()
defer mq.mutex.Unlock()
if worker, exists := mq.workers[workerID]; exists {
worker.LastHeartbeat = time.Now()
}
}
// GetRunningTaskCount returns the number of running tasks of a specific type
func (mq *MaintenanceQueue) GetRunningTaskCount(taskType MaintenanceTaskType) int {
mq.mutex.RLock()
defer mq.mutex.RUnlock()
count := 0
for _, task := range mq.tasks {
if task.Type == taskType && (task.Status == TaskStatusAssigned || task.Status == TaskStatusInProgress) {
count++
}
}
return count
}
// WasTaskRecentlyCompleted checks if a similar task was recently completed
func (mq *MaintenanceQueue) WasTaskRecentlyCompleted(taskType MaintenanceTaskType, volumeID uint32, server string, now time.Time) bool {
mq.mutex.RLock()
defer mq.mutex.RUnlock()
// Get the repeat prevention interval for this task type
interval := mq.getRepeatPreventionInterval(taskType)
cutoff := now.Add(-interval)
for _, task := range mq.tasks {
if task.Type == taskType &&
task.VolumeID == volumeID &&
task.Server == server &&
task.Status == TaskStatusCompleted &&
task.CompletedAt != nil &&
task.CompletedAt.After(cutoff) {
return true
}
}
return false
}
// getRepeatPreventionInterval returns the interval for preventing task repetition
func (mq *MaintenanceQueue) getRepeatPreventionInterval(taskType MaintenanceTaskType) time.Duration {
// First try to get default from task scheduler
if mq.integration != nil {
if scheduler := mq.integration.GetTaskScheduler(taskType); scheduler != nil {
defaultInterval := scheduler.GetDefaultRepeatInterval()
if defaultInterval > 0 {
glog.V(3).Infof("Using task scheduler default repeat interval for %s: %v", taskType, defaultInterval)
return defaultInterval
}
}
}
// Fallback to policy configuration if no scheduler available or scheduler doesn't provide default
if mq.policy != nil {
repeatIntervalHours := mq.policy.GetRepeatInterval(taskType)
if repeatIntervalHours > 0 {
interval := time.Duration(repeatIntervalHours) * time.Hour
glog.V(3).Infof("Using policy configuration repeat interval for %s: %v", taskType, interval)
return interval
}
}
// Ultimate fallback - but avoid hardcoded values where possible
glog.V(2).Infof("No scheduler or policy configuration found for task type %s, using minimal default: 1h", taskType)
return time.Hour // Minimal safe default
}
// GetTasks returns tasks with optional filtering
func (mq *MaintenanceQueue) GetTasks(status MaintenanceTaskStatus, taskType MaintenanceTaskType, limit int) []*MaintenanceTask {
mq.mutex.RLock()
defer mq.mutex.RUnlock()
var tasks []*MaintenanceTask
for _, task := range mq.tasks {
if status != "" && task.Status != status {
continue
}
if taskType != "" && task.Type != taskType {
continue
}
tasks = append(tasks, task)
if limit > 0 && len(tasks) >= limit {
break
}
}
// Sort by creation time (newest first)
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
})
return tasks
}
// GetWorkers returns all registered workers
func (mq *MaintenanceQueue) GetWorkers() []*MaintenanceWorker {
mq.mutex.RLock()
defer mq.mutex.RUnlock()
var workers []*MaintenanceWorker
for _, worker := range mq.workers {
workers = append(workers, worker)
}
return workers
}
// generateTaskID generates a unique ID for tasks
func generateTaskID() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
for i := range b {
b[i] = charset[i%len(charset)]
}
return string(b)
}
// CleanupOldTasks removes old completed and failed tasks
func (mq *MaintenanceQueue) CleanupOldTasks(retention time.Duration) int {
mq.mutex.Lock()
defer mq.mutex.Unlock()
cutoff := time.Now().Add(-retention)
removed := 0
for id, task := range mq.tasks {
if (task.Status == TaskStatusCompleted || task.Status == TaskStatusFailed) &&
task.CompletedAt != nil &&
task.CompletedAt.Before(cutoff) {
delete(mq.tasks, id)
removed++
}
}
glog.V(2).Infof("Cleaned up %d old maintenance tasks", removed)
return removed
}
// RemoveStaleWorkers removes workers that haven't sent heartbeat recently
func (mq *MaintenanceQueue) RemoveStaleWorkers(timeout time.Duration) int {
mq.mutex.Lock()
defer mq.mutex.Unlock()
cutoff := time.Now().Add(-timeout)
removed := 0
for id, worker := range mq.workers {
if worker.LastHeartbeat.Before(cutoff) {
// Mark any assigned tasks as failed
for _, task := range mq.tasks {
if task.WorkerID == id && (task.Status == TaskStatusAssigned || task.Status == TaskStatusInProgress) {
task.Status = TaskStatusFailed
task.Error = "Worker became unavailable"
completedTime := time.Now()
task.CompletedAt = &completedTime
}
}
delete(mq.workers, id)
removed++
glog.Warningf("Removed stale maintenance worker %s", id)
}
}
return removed
}
// GetStats returns maintenance statistics
func (mq *MaintenanceQueue) GetStats() *MaintenanceStats {
mq.mutex.RLock()
defer mq.mutex.RUnlock()
stats := &MaintenanceStats{
TotalTasks: len(mq.tasks),
TasksByStatus: make(map[MaintenanceTaskStatus]int),
TasksByType: make(map[MaintenanceTaskType]int),
ActiveWorkers: 0,
}
today := time.Now().Truncate(24 * time.Hour)
var totalDuration time.Duration
var completedTasks int
for _, task := range mq.tasks {
stats.TasksByStatus[task.Status]++
stats.TasksByType[task.Type]++
if task.CompletedAt != nil && task.CompletedAt.After(today) {
if task.Status == TaskStatusCompleted {
stats.CompletedToday++
} else if task.Status == TaskStatusFailed {
stats.FailedToday++
}
if task.StartedAt != nil {
duration := task.CompletedAt.Sub(*task.StartedAt)
totalDuration += duration
completedTasks++
}
}
}
for _, worker := range mq.workers {
if worker.Status == "active" || worker.Status == "busy" {
stats.ActiveWorkers++
}
}
if completedTasks > 0 {
stats.AverageTaskTime = totalDuration / time.Duration(completedTasks)
}
return stats
}
// workerCanHandle checks if a worker can handle a specific task type
func (mq *MaintenanceQueue) workerCanHandle(taskType MaintenanceTaskType, capabilities []MaintenanceTaskType) bool {
for _, capability := range capabilities {
if capability == taskType {
return true
}
}
return false
}
// canScheduleTaskNow determines if a task can be scheduled using task schedulers or fallback logic
func (mq *MaintenanceQueue) canScheduleTaskNow(task *MaintenanceTask) bool {
// Try task scheduling logic first
if mq.integration != nil {
// Get all running tasks and available workers
runningTasks := mq.getRunningTasks()
availableWorkers := mq.getAvailableWorkers()
canSchedule := mq.integration.CanScheduleWithTaskSchedulers(task, runningTasks, availableWorkers)
glog.V(3).Infof("Task scheduler decision for task %s (%s): %v", task.ID, task.Type, canSchedule)
return canSchedule
}
// Fallback to hardcoded logic
return mq.canExecuteTaskType(task.Type)
}
// canExecuteTaskType checks if we can execute more tasks of this type (concurrency limits) - fallback logic
func (mq *MaintenanceQueue) canExecuteTaskType(taskType MaintenanceTaskType) bool {
runningCount := mq.GetRunningTaskCount(taskType)
maxConcurrent := mq.getMaxConcurrentForTaskType(taskType)
return runningCount < maxConcurrent
}
// getMaxConcurrentForTaskType returns the maximum concurrent tasks allowed for a task type
func (mq *MaintenanceQueue) getMaxConcurrentForTaskType(taskType MaintenanceTaskType) int {
// First try to get default from task scheduler
if mq.integration != nil {
if scheduler := mq.integration.GetTaskScheduler(taskType); scheduler != nil {
maxConcurrent := scheduler.GetMaxConcurrent()
if maxConcurrent > 0 {
glog.V(3).Infof("Using task scheduler max concurrent for %s: %d", taskType, maxConcurrent)
return maxConcurrent
}
}
}
// Fallback to policy configuration if no scheduler available or scheduler doesn't provide default
if mq.policy != nil {
maxConcurrent := mq.policy.GetMaxConcurrent(taskType)
if maxConcurrent > 0 {
glog.V(3).Infof("Using policy configuration max concurrent for %s: %d", taskType, maxConcurrent)
return maxConcurrent
}
}
// Ultimate fallback - minimal safe default
glog.V(2).Infof("No scheduler or policy configuration found for task type %s, using minimal default: 1", taskType)
return 1
}
// getRunningTasks returns all currently running tasks
func (mq *MaintenanceQueue) getRunningTasks() []*MaintenanceTask {
var runningTasks []*MaintenanceTask
for _, task := range mq.tasks {
if task.Status == TaskStatusAssigned || task.Status == TaskStatusInProgress {
runningTasks = append(runningTasks, task)
}
}
return runningTasks
}
// getAvailableWorkers returns all workers that can take more work
func (mq *MaintenanceQueue) getAvailableWorkers() []*MaintenanceWorker {
var availableWorkers []*MaintenanceWorker
for _, worker := range mq.workers {
if worker.Status == "active" && worker.CurrentLoad < worker.MaxConcurrent {
availableWorkers = append(availableWorkers, worker)
}
}
return availableWorkers
}
@@ -0,0 +1,163 @@
package maintenance
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// NewMaintenanceScanner creates a new maintenance scanner
func NewMaintenanceScanner(adminClient AdminClient, policy *MaintenancePolicy, queue *MaintenanceQueue) *MaintenanceScanner {
scanner := &MaintenanceScanner{
adminClient: adminClient,
policy: policy,
queue: queue,
lastScan: make(map[MaintenanceTaskType]time.Time),
}
// Initialize integration
scanner.integration = NewMaintenanceIntegration(queue, policy)
// Set up bidirectional relationship
queue.SetIntegration(scanner.integration)
glog.V(1).Infof("Initialized maintenance scanner with task system")
return scanner
}
// ScanForMaintenanceTasks analyzes the cluster and generates maintenance tasks
func (ms *MaintenanceScanner) ScanForMaintenanceTasks() ([]*TaskDetectionResult, error) {
// Get volume health metrics
volumeMetrics, err := ms.getVolumeHealthMetrics()
if err != nil {
return nil, fmt.Errorf("failed to get volume health metrics: %v", err)
}
// Use task system for all task types
if ms.integration != nil {
// Convert metrics to task system format
taskMetrics := ms.convertToTaskMetrics(volumeMetrics)
// Use task detection system
results, err := ms.integration.ScanWithTaskDetectors(taskMetrics)
if err != nil {
glog.Errorf("Task scanning failed: %v", err)
return nil, err
}
glog.V(1).Infof("Maintenance scan completed: found %d tasks", len(results))
return results, nil
}
// No integration available
glog.Warningf("No integration available, no tasks will be scheduled")
return []*TaskDetectionResult{}, nil
}
// getVolumeHealthMetrics collects health information for all volumes
func (ms *MaintenanceScanner) getVolumeHealthMetrics() ([]*VolumeHealthMetrics, error) {
var metrics []*VolumeHealthMetrics
err := ms.adminClient.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.VolumeList(context.Background(), &master_pb.VolumeListRequest{})
if err != nil {
return err
}
if resp.TopologyInfo == nil {
return nil
}
for _, dc := range resp.TopologyInfo.DataCenterInfos {
for _, rack := range dc.RackInfos {
for _, node := range rack.DataNodeInfos {
for _, diskInfo := range node.DiskInfos {
for _, volInfo := range diskInfo.VolumeInfos {
metric := &VolumeHealthMetrics{
VolumeID: volInfo.Id,
Server: node.Id,
Collection: volInfo.Collection,
Size: volInfo.Size,
DeletedBytes: volInfo.DeletedByteCount,
LastModified: time.Unix(int64(volInfo.ModifiedAtSecond), 0),
IsReadOnly: volInfo.ReadOnly,
IsECVolume: false, // Will be determined from volume structure
ReplicaCount: 1, // Will be counted
ExpectedReplicas: int(volInfo.ReplicaPlacement),
}
// Calculate derived metrics
if metric.Size > 0 {
metric.GarbageRatio = float64(metric.DeletedBytes) / float64(metric.Size)
// Calculate fullness ratio (would need volume size limit)
// metric.FullnessRatio = float64(metric.Size) / float64(volumeSizeLimit)
}
metric.Age = time.Since(metric.LastModified)
metrics = append(metrics, metric)
}
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
// Count actual replicas and identify EC volumes
ms.enrichVolumeMetrics(metrics)
return metrics, nil
}
// enrichVolumeMetrics adds additional information like replica counts
func (ms *MaintenanceScanner) enrichVolumeMetrics(metrics []*VolumeHealthMetrics) {
// Group volumes by ID to count replicas
volumeGroups := make(map[uint32][]*VolumeHealthMetrics)
for _, metric := range metrics {
volumeGroups[metric.VolumeID] = append(volumeGroups[metric.VolumeID], metric)
}
// Update replica counts
for _, group := range volumeGroups {
actualReplicas := len(group)
for _, metric := range group {
metric.ReplicaCount = actualReplicas
}
}
}
// convertToTaskMetrics converts existing volume metrics to task system format
func (ms *MaintenanceScanner) convertToTaskMetrics(metrics []*VolumeHealthMetrics) []*types.VolumeHealthMetrics {
var simplified []*types.VolumeHealthMetrics
for _, metric := range metrics {
simplified = append(simplified, &types.VolumeHealthMetrics{
VolumeID: metric.VolumeID,
Server: metric.Server,
Collection: metric.Collection,
Size: metric.Size,
DeletedBytes: metric.DeletedBytes,
GarbageRatio: metric.GarbageRatio,
LastModified: metric.LastModified,
Age: metric.Age,
ReplicaCount: metric.ReplicaCount,
ExpectedReplicas: metric.ExpectedReplicas,
IsReadOnly: metric.IsReadOnly,
HasRemoteCopy: metric.HasRemoteCopy,
IsECVolume: metric.IsECVolume,
FullnessRatio: metric.FullnessRatio,
})
}
return simplified
}
+560
View File
@@ -0,0 +1,560 @@
package maintenance
import (
"html/template"
"sort"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// AdminClient interface defines what the maintenance system needs from the admin server
type AdminClient interface {
WithMasterClient(fn func(client master_pb.SeaweedClient) error) error
}
// MaintenanceTaskType represents different types of maintenance operations
type MaintenanceTaskType string
// GetRegisteredMaintenanceTaskTypes returns all registered task types as MaintenanceTaskType values
// sorted alphabetically for consistent menu ordering
func GetRegisteredMaintenanceTaskTypes() []MaintenanceTaskType {
typesRegistry := tasks.GetGlobalTypesRegistry()
var taskTypes []MaintenanceTaskType
for workerTaskType := range typesRegistry.GetAllDetectors() {
maintenanceTaskType := MaintenanceTaskType(string(workerTaskType))
taskTypes = append(taskTypes, maintenanceTaskType)
}
// Sort task types alphabetically to ensure consistent menu ordering
sort.Slice(taskTypes, func(i, j int) bool {
return string(taskTypes[i]) < string(taskTypes[j])
})
return taskTypes
}
// GetMaintenanceTaskType returns a specific task type if it's registered, or empty string if not found
func GetMaintenanceTaskType(taskTypeName string) MaintenanceTaskType {
typesRegistry := tasks.GetGlobalTypesRegistry()
for workerTaskType := range typesRegistry.GetAllDetectors() {
if string(workerTaskType) == taskTypeName {
return MaintenanceTaskType(taskTypeName)
}
}
return MaintenanceTaskType("")
}
// IsMaintenanceTaskTypeRegistered checks if a task type is registered
func IsMaintenanceTaskTypeRegistered(taskType MaintenanceTaskType) bool {
typesRegistry := tasks.GetGlobalTypesRegistry()
for workerTaskType := range typesRegistry.GetAllDetectors() {
if string(workerTaskType) == string(taskType) {
return true
}
}
return false
}
// MaintenanceTaskPriority represents task execution priority
type MaintenanceTaskPriority int
const (
PriorityLow MaintenanceTaskPriority = iota
PriorityNormal
PriorityHigh
PriorityCritical
)
// MaintenanceTaskStatus represents the current status of a task
type MaintenanceTaskStatus string
const (
TaskStatusPending MaintenanceTaskStatus = "pending"
TaskStatusAssigned MaintenanceTaskStatus = "assigned"
TaskStatusInProgress MaintenanceTaskStatus = "in_progress"
TaskStatusCompleted MaintenanceTaskStatus = "completed"
TaskStatusFailed MaintenanceTaskStatus = "failed"
TaskStatusCancelled MaintenanceTaskStatus = "cancelled"
)
// MaintenanceTask represents a single maintenance operation
type MaintenanceTask struct {
ID string `json:"id"`
Type MaintenanceTaskType `json:"type"`
Priority MaintenanceTaskPriority `json:"priority"`
Status MaintenanceTaskStatus `json:"status"`
VolumeID uint32 `json:"volume_id,omitempty"`
Server string `json:"server,omitempty"`
Collection string `json:"collection,omitempty"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
WorkerID string `json:"worker_id,omitempty"`
Error string `json:"error,omitempty"`
Progress float64 `json:"progress"` // 0-100
RetryCount int `json:"retry_count"`
MaxRetries int `json:"max_retries"`
}
// TaskPolicy represents configuration for a specific task type
type TaskPolicy struct {
Enabled bool `json:"enabled"`
MaxConcurrent int `json:"max_concurrent"`
RepeatInterval int `json:"repeat_interval"` // Hours to wait before repeating
CheckInterval int `json:"check_interval"` // Hours between checks
Configuration map[string]interface{} `json:"configuration"` // Task-specific config
}
// MaintenancePolicy defines policies for maintenance operations using a dynamic structure
type MaintenancePolicy struct {
// Task-specific policies mapped by task type
TaskPolicies map[MaintenanceTaskType]*TaskPolicy `json:"task_policies"`
// Global policy settings
GlobalMaxConcurrent int `json:"global_max_concurrent"` // Overall limit across all task types
DefaultRepeatInterval int `json:"default_repeat_interval"` // Default hours if task doesn't specify
DefaultCheckInterval int `json:"default_check_interval"` // Default hours for periodic checks
}
// GetTaskPolicy returns the policy for a specific task type, creating generic defaults if needed
func (mp *MaintenancePolicy) GetTaskPolicy(taskType MaintenanceTaskType) *TaskPolicy {
if mp.TaskPolicies == nil {
mp.TaskPolicies = make(map[MaintenanceTaskType]*TaskPolicy)
}
policy, exists := mp.TaskPolicies[taskType]
if !exists {
// Create generic default policy using global settings - no hardcoded fallbacks
policy = &TaskPolicy{
Enabled: false, // Conservative default - require explicit enabling
MaxConcurrent: 1, // Conservative default concurrency
RepeatInterval: mp.DefaultRepeatInterval, // Use configured default, 0 if not set
CheckInterval: mp.DefaultCheckInterval, // Use configured default, 0 if not set
Configuration: make(map[string]interface{}),
}
mp.TaskPolicies[taskType] = policy
}
return policy
}
// SetTaskPolicy sets the policy for a specific task type
func (mp *MaintenancePolicy) SetTaskPolicy(taskType MaintenanceTaskType, policy *TaskPolicy) {
if mp.TaskPolicies == nil {
mp.TaskPolicies = make(map[MaintenanceTaskType]*TaskPolicy)
}
mp.TaskPolicies[taskType] = policy
}
// IsTaskEnabled returns whether a task type is enabled
func (mp *MaintenancePolicy) IsTaskEnabled(taskType MaintenanceTaskType) bool {
policy := mp.GetTaskPolicy(taskType)
return policy.Enabled
}
// GetMaxConcurrent returns the max concurrent limit for a task type
func (mp *MaintenancePolicy) GetMaxConcurrent(taskType MaintenanceTaskType) int {
policy := mp.GetTaskPolicy(taskType)
return policy.MaxConcurrent
}
// GetRepeatInterval returns the repeat interval for a task type
func (mp *MaintenancePolicy) GetRepeatInterval(taskType MaintenanceTaskType) int {
policy := mp.GetTaskPolicy(taskType)
return policy.RepeatInterval
}
// GetTaskConfig returns a configuration value for a task type
func (mp *MaintenancePolicy) GetTaskConfig(taskType MaintenanceTaskType, key string) (interface{}, bool) {
policy := mp.GetTaskPolicy(taskType)
value, exists := policy.Configuration[key]
return value, exists
}
// SetTaskConfig sets a configuration value for a task type
func (mp *MaintenancePolicy) SetTaskConfig(taskType MaintenanceTaskType, key string, value interface{}) {
policy := mp.GetTaskPolicy(taskType)
if policy.Configuration == nil {
policy.Configuration = make(map[string]interface{})
}
policy.Configuration[key] = value
}
// MaintenanceWorker represents a worker instance
type MaintenanceWorker struct {
ID string `json:"id"`
Address string `json:"address"`
LastHeartbeat time.Time `json:"last_heartbeat"`
Status string `json:"status"` // active, inactive, busy
CurrentTask *MaintenanceTask `json:"current_task,omitempty"`
Capabilities []MaintenanceTaskType `json:"capabilities"`
MaxConcurrent int `json:"max_concurrent"`
CurrentLoad int `json:"current_load"`
}
// MaintenanceQueue manages the task queue and worker coordination
type MaintenanceQueue struct {
tasks map[string]*MaintenanceTask
workers map[string]*MaintenanceWorker
pendingTasks []*MaintenanceTask
mutex sync.RWMutex
policy *MaintenancePolicy
integration *MaintenanceIntegration
}
// MaintenanceScanner analyzes the cluster and generates maintenance tasks
type MaintenanceScanner struct {
adminClient AdminClient
policy *MaintenancePolicy
queue *MaintenanceQueue
lastScan map[MaintenanceTaskType]time.Time
integration *MaintenanceIntegration
}
// TaskDetectionResult represents the result of scanning for maintenance needs
type TaskDetectionResult struct {
TaskType MaintenanceTaskType `json:"task_type"`
VolumeID uint32 `json:"volume_id,omitempty"`
Server string `json:"server,omitempty"`
Collection string `json:"collection,omitempty"`
Priority MaintenanceTaskPriority `json:"priority"`
Reason string `json:"reason"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
ScheduleAt time.Time `json:"schedule_at"`
}
// VolumeHealthMetrics contains health information about a volume
type VolumeHealthMetrics struct {
VolumeID uint32 `json:"volume_id"`
Server string `json:"server"`
Collection string `json:"collection"`
Size uint64 `json:"size"`
DeletedBytes uint64 `json:"deleted_bytes"`
GarbageRatio float64 `json:"garbage_ratio"`
LastModified time.Time `json:"last_modified"`
Age time.Duration `json:"age"`
ReplicaCount int `json:"replica_count"`
ExpectedReplicas int `json:"expected_replicas"`
IsReadOnly bool `json:"is_read_only"`
HasRemoteCopy bool `json:"has_remote_copy"`
IsECVolume bool `json:"is_ec_volume"`
FullnessRatio float64 `json:"fullness_ratio"`
}
// MaintenanceStats provides statistics about maintenance operations
type MaintenanceStats struct {
TotalTasks int `json:"total_tasks"`
TasksByStatus map[MaintenanceTaskStatus]int `json:"tasks_by_status"`
TasksByType map[MaintenanceTaskType]int `json:"tasks_by_type"`
ActiveWorkers int `json:"active_workers"`
CompletedToday int `json:"completed_today"`
FailedToday int `json:"failed_today"`
AverageTaskTime time.Duration `json:"average_task_time"`
LastScanTime time.Time `json:"last_scan_time"`
NextScanTime time.Time `json:"next_scan_time"`
}
// MaintenanceConfig holds configuration for the maintenance system
type MaintenanceConfig struct {
Enabled bool `json:"enabled"`
ScanIntervalSeconds int `json:"scan_interval_seconds"` // How often to scan for maintenance needs (in seconds)
WorkerTimeoutSeconds int `json:"worker_timeout_seconds"` // Worker heartbeat timeout (in seconds)
TaskTimeoutSeconds int `json:"task_timeout_seconds"` // Individual task timeout (in seconds)
RetryDelaySeconds int `json:"retry_delay_seconds"` // Delay between retries (in seconds)
MaxRetries int `json:"max_retries"` // Default max retries for tasks
CleanupIntervalSeconds int `json:"cleanup_interval_seconds"` // How often to clean up old tasks (in seconds)
TaskRetentionSeconds int `json:"task_retention_seconds"` // How long to keep completed/failed tasks (in seconds)
Policy *MaintenancePolicy `json:"policy"`
}
// Default configuration values
func DefaultMaintenanceConfig() *MaintenanceConfig {
return &MaintenanceConfig{
Enabled: false, // Disabled by default for safety
ScanIntervalSeconds: 30 * 60, // 30 minutes
WorkerTimeoutSeconds: 5 * 60, // 5 minutes
TaskTimeoutSeconds: 2 * 60 * 60, // 2 hours
RetryDelaySeconds: 15 * 60, // 15 minutes
MaxRetries: 3,
CleanupIntervalSeconds: 24 * 60 * 60, // 24 hours
TaskRetentionSeconds: 7 * 24 * 60 * 60, // 7 days
Policy: &MaintenancePolicy{
GlobalMaxConcurrent: 4,
DefaultRepeatInterval: 6,
DefaultCheckInterval: 12,
},
}
}
// MaintenanceQueueData represents data for the queue visualization UI
type MaintenanceQueueData struct {
Tasks []*MaintenanceTask `json:"tasks"`
Workers []*MaintenanceWorker `json:"workers"`
Stats *QueueStats `json:"stats"`
LastUpdated time.Time `json:"last_updated"`
}
// QueueStats provides statistics for the queue UI
type QueueStats struct {
PendingTasks int `json:"pending_tasks"`
RunningTasks int `json:"running_tasks"`
CompletedToday int `json:"completed_today"`
FailedToday int `json:"failed_today"`
TotalTasks int `json:"total_tasks"`
}
// MaintenanceConfigData represents configuration data for the UI
type MaintenanceConfigData struct {
Config *MaintenanceConfig `json:"config"`
IsEnabled bool `json:"is_enabled"`
LastScanTime time.Time `json:"last_scan_time"`
NextScanTime time.Time `json:"next_scan_time"`
SystemStats *MaintenanceStats `json:"system_stats"`
MenuItems []*MaintenanceMenuItem `json:"menu_items"`
}
// MaintenanceMenuItem represents a menu item for task configuration
type MaintenanceMenuItem struct {
TaskType MaintenanceTaskType `json:"task_type"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
IsEnabled bool `json:"is_enabled"`
Path string `json:"path"`
}
// WorkerDetailsData represents detailed worker information
type WorkerDetailsData struct {
Worker *MaintenanceWorker `json:"worker"`
CurrentTasks []*MaintenanceTask `json:"current_tasks"`
RecentTasks []*MaintenanceTask `json:"recent_tasks"`
Performance *WorkerPerformance `json:"performance"`
LastUpdated time.Time `json:"last_updated"`
}
// WorkerPerformance tracks worker performance metrics
type WorkerPerformance struct {
TasksCompleted int `json:"tasks_completed"`
TasksFailed int `json:"tasks_failed"`
AverageTaskTime time.Duration `json:"average_task_time"`
Uptime time.Duration `json:"uptime"`
SuccessRate float64 `json:"success_rate"`
}
// TaskConfigData represents data for individual task configuration page
type TaskConfigData struct {
TaskType MaintenanceTaskType `json:"task_type"`
TaskName string `json:"task_name"`
TaskIcon string `json:"task_icon"`
Description string `json:"description"`
ConfigFormHTML template.HTML `json:"config_form_html"`
}
// ClusterReplicationTask represents a cluster replication task parameters
type ClusterReplicationTask struct {
SourcePath string `json:"source_path"`
TargetCluster string `json:"target_cluster"`
TargetPath string `json:"target_path"`
ReplicationMode string `json:"replication_mode"` // "sync", "async", "backup"
Priority int `json:"priority"`
Checksum string `json:"checksum,omitempty"`
FileSize int64 `json:"file_size"`
CreatedAt time.Time `json:"created_at"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// BuildMaintenancePolicyFromTasks creates a maintenance policy with configurations
// from all registered tasks using their UI providers
func BuildMaintenancePolicyFromTasks() *MaintenancePolicy {
policy := &MaintenancePolicy{
TaskPolicies: make(map[MaintenanceTaskType]*TaskPolicy),
GlobalMaxConcurrent: 4,
DefaultRepeatInterval: 6,
DefaultCheckInterval: 12,
}
// Get all registered task types from the UI registry
uiRegistry := tasks.GetGlobalUIRegistry()
typesRegistry := tasks.GetGlobalTypesRegistry()
for taskType, provider := range uiRegistry.GetAllProviders() {
// Convert task type to maintenance task type
maintenanceTaskType := MaintenanceTaskType(string(taskType))
// Get the default configuration from the UI provider
defaultConfig := provider.GetCurrentConfig()
// Create task policy from UI configuration
taskPolicy := &TaskPolicy{
Enabled: true, // Default enabled
MaxConcurrent: 2, // Default concurrency
RepeatInterval: policy.DefaultRepeatInterval,
CheckInterval: policy.DefaultCheckInterval,
Configuration: make(map[string]interface{}),
}
// Extract configuration from UI provider's config
if configMap, ok := defaultConfig.(map[string]interface{}); ok {
// Copy all configuration values
for key, value := range configMap {
taskPolicy.Configuration[key] = value
}
// Extract common fields
if enabled, exists := configMap["enabled"]; exists {
if enabledBool, ok := enabled.(bool); ok {
taskPolicy.Enabled = enabledBool
}
}
if maxConcurrent, exists := configMap["max_concurrent"]; exists {
if maxConcurrentInt, ok := maxConcurrent.(int); ok {
taskPolicy.MaxConcurrent = maxConcurrentInt
} else if maxConcurrentFloat, ok := maxConcurrent.(float64); ok {
taskPolicy.MaxConcurrent = int(maxConcurrentFloat)
}
}
}
// Also get defaults from scheduler if available (using types.TaskScheduler explicitly)
var scheduler types.TaskScheduler = typesRegistry.GetScheduler(taskType)
if scheduler != nil {
if taskPolicy.MaxConcurrent <= 0 {
taskPolicy.MaxConcurrent = scheduler.GetMaxConcurrent()
}
// Convert default repeat interval to hours
if repeatInterval := scheduler.GetDefaultRepeatInterval(); repeatInterval > 0 {
taskPolicy.RepeatInterval = int(repeatInterval.Hours())
}
}
// Also get defaults from detector if available (using types.TaskDetector explicitly)
var detector types.TaskDetector = typesRegistry.GetDetector(taskType)
if detector != nil {
// Convert scan interval to check interval (hours)
if scanInterval := detector.ScanInterval(); scanInterval > 0 {
taskPolicy.CheckInterval = int(scanInterval.Hours())
}
}
policy.TaskPolicies[maintenanceTaskType] = taskPolicy
glog.V(3).Infof("Built policy for task type %s: enabled=%v, max_concurrent=%d",
maintenanceTaskType, taskPolicy.Enabled, taskPolicy.MaxConcurrent)
}
glog.V(2).Infof("Built maintenance policy with %d task configurations", len(policy.TaskPolicies))
return policy
}
// SetPolicyFromTasks sets the maintenance policy from registered tasks
func SetPolicyFromTasks(policy *MaintenancePolicy) {
if policy == nil {
return
}
// Build new policy from tasks
newPolicy := BuildMaintenancePolicyFromTasks()
// Copy task policies
policy.TaskPolicies = newPolicy.TaskPolicies
glog.V(1).Infof("Updated maintenance policy with %d task configurations from registered tasks", len(policy.TaskPolicies))
}
// GetTaskIcon returns the icon CSS class for a task type from its UI provider
func GetTaskIcon(taskType MaintenanceTaskType) string {
typesRegistry := tasks.GetGlobalTypesRegistry()
uiRegistry := tasks.GetGlobalUIRegistry()
// Convert MaintenanceTaskType to TaskType
for workerTaskType := range typesRegistry.GetAllDetectors() {
if string(workerTaskType) == string(taskType) {
// Get the UI provider for this task type
provider := uiRegistry.GetProvider(workerTaskType)
if provider != nil {
return provider.GetIcon()
}
break
}
}
// Default icon if no UI provider found
return "fas fa-cog text-muted"
}
// GetTaskDisplayName returns the display name for a task type from its UI provider
func GetTaskDisplayName(taskType MaintenanceTaskType) string {
typesRegistry := tasks.GetGlobalTypesRegistry()
uiRegistry := tasks.GetGlobalUIRegistry()
// Convert MaintenanceTaskType to TaskType
for workerTaskType := range typesRegistry.GetAllDetectors() {
if string(workerTaskType) == string(taskType) {
// Get the UI provider for this task type
provider := uiRegistry.GetProvider(workerTaskType)
if provider != nil {
return provider.GetDisplayName()
}
break
}
}
// Fallback to the task type string
return string(taskType)
}
// GetTaskDescription returns the description for a task type from its UI provider
func GetTaskDescription(taskType MaintenanceTaskType) string {
typesRegistry := tasks.GetGlobalTypesRegistry()
uiRegistry := tasks.GetGlobalUIRegistry()
// Convert MaintenanceTaskType to TaskType
for workerTaskType := range typesRegistry.GetAllDetectors() {
if string(workerTaskType) == string(taskType) {
// Get the UI provider for this task type
provider := uiRegistry.GetProvider(workerTaskType)
if provider != nil {
return provider.GetDescription()
}
break
}
}
// Fallback to a generic description
return "Configure detailed settings for " + string(taskType) + " tasks."
}
// BuildMaintenanceMenuItems creates menu items for all registered task types
func BuildMaintenanceMenuItems() []*MaintenanceMenuItem {
var menuItems []*MaintenanceMenuItem
// Get all registered task types
registeredTypes := GetRegisteredMaintenanceTaskTypes()
for _, taskType := range registeredTypes {
menuItem := &MaintenanceMenuItem{
TaskType: taskType,
DisplayName: GetTaskDisplayName(taskType),
Description: GetTaskDescription(taskType),
Icon: GetTaskIcon(taskType),
IsEnabled: IsMaintenanceTaskTypeRegistered(taskType),
Path: "/maintenance/config/" + string(taskType),
}
menuItems = append(menuItems, menuItem)
}
return menuItems
}
@@ -0,0 +1,413 @@
package maintenance
import (
"fmt"
"os"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
// Import task packages to trigger their auto-registration
_ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
_ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
_ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
)
// MaintenanceWorkerService manages maintenance task execution
// TaskExecutor defines the function signature for task execution
type TaskExecutor func(*MaintenanceWorkerService, *MaintenanceTask) error
// TaskExecutorFactory creates a task executor for a given worker service
type TaskExecutorFactory func() TaskExecutor
// Global registry for task executor factories
var taskExecutorFactories = make(map[MaintenanceTaskType]TaskExecutorFactory)
var executorRegistryMutex sync.RWMutex
var executorRegistryInitOnce sync.Once
// initializeExecutorFactories dynamically registers executor factories for all auto-registered task types
func initializeExecutorFactories() {
executorRegistryInitOnce.Do(func() {
// Get all registered task types from the global registry
typesRegistry := tasks.GetGlobalTypesRegistry()
var taskTypes []MaintenanceTaskType
for workerTaskType := range typesRegistry.GetAllDetectors() {
// Convert types.TaskType to MaintenanceTaskType by string conversion
maintenanceTaskType := MaintenanceTaskType(string(workerTaskType))
taskTypes = append(taskTypes, maintenanceTaskType)
}
// Register generic executor for all task types
for _, taskType := range taskTypes {
RegisterTaskExecutorFactory(taskType, createGenericTaskExecutor)
}
glog.V(1).Infof("Dynamically registered generic task executor for %d task types: %v", len(taskTypes), taskTypes)
})
}
// RegisterTaskExecutorFactory registers a factory function for creating task executors
func RegisterTaskExecutorFactory(taskType MaintenanceTaskType, factory TaskExecutorFactory) {
executorRegistryMutex.Lock()
defer executorRegistryMutex.Unlock()
taskExecutorFactories[taskType] = factory
glog.V(2).Infof("Registered executor factory for task type: %s", taskType)
}
// GetTaskExecutorFactory returns the factory for a task type
func GetTaskExecutorFactory(taskType MaintenanceTaskType) (TaskExecutorFactory, bool) {
// Ensure executor factories are initialized
initializeExecutorFactories()
executorRegistryMutex.RLock()
defer executorRegistryMutex.RUnlock()
factory, exists := taskExecutorFactories[taskType]
return factory, exists
}
// GetSupportedExecutorTaskTypes returns all task types with registered executor factories
func GetSupportedExecutorTaskTypes() []MaintenanceTaskType {
// Ensure executor factories are initialized
initializeExecutorFactories()
executorRegistryMutex.RLock()
defer executorRegistryMutex.RUnlock()
taskTypes := make([]MaintenanceTaskType, 0, len(taskExecutorFactories))
for taskType := range taskExecutorFactories {
taskTypes = append(taskTypes, taskType)
}
return taskTypes
}
// createGenericTaskExecutor creates a generic task executor that uses the task registry
func createGenericTaskExecutor() TaskExecutor {
return func(mws *MaintenanceWorkerService, task *MaintenanceTask) error {
return mws.executeGenericTask(task)
}
}
// init does minimal initialization - actual registration happens lazily
func init() {
// Executor factory registration will happen lazily when first accessed
glog.V(1).Infof("Maintenance worker initialized - executor factories will be registered on first access")
}
type MaintenanceWorkerService struct {
workerID string
address string
adminServer string
capabilities []MaintenanceTaskType
maxConcurrent int
currentTasks map[string]*MaintenanceTask
queue *MaintenanceQueue
adminClient AdminClient
running bool
stopChan chan struct{}
// Task execution registry
taskExecutors map[MaintenanceTaskType]TaskExecutor
// Task registry for creating task instances
taskRegistry *tasks.TaskRegistry
}
// NewMaintenanceWorkerService creates a new maintenance worker service
func NewMaintenanceWorkerService(workerID, address, adminServer string) *MaintenanceWorkerService {
// Get all registered maintenance task types dynamically
capabilities := GetRegisteredMaintenanceTaskTypes()
worker := &MaintenanceWorkerService{
workerID: workerID,
address: address,
adminServer: adminServer,
capabilities: capabilities,
maxConcurrent: 2, // Default concurrent task limit
currentTasks: make(map[string]*MaintenanceTask),
stopChan: make(chan struct{}),
taskExecutors: make(map[MaintenanceTaskType]TaskExecutor),
taskRegistry: tasks.GetGlobalRegistry(), // Use global registry with auto-registered tasks
}
// Initialize task executor registry
worker.initializeTaskExecutors()
glog.V(1).Infof("Created maintenance worker with %d registered task types", len(worker.taskRegistry.GetSupportedTypes()))
return worker
}
// executeGenericTask executes a task using the task registry instead of hardcoded methods
func (mws *MaintenanceWorkerService) executeGenericTask(task *MaintenanceTask) error {
glog.V(2).Infof("Executing generic task %s: %s for volume %d", task.ID, task.Type, task.VolumeID)
// Convert MaintenanceTask to types.TaskType
taskType := types.TaskType(string(task.Type))
// Create task parameters
taskParams := types.TaskParams{
VolumeID: task.VolumeID,
Server: task.Server,
Collection: task.Collection,
Parameters: task.Parameters,
}
// Create task instance using the registry
taskInstance, err := mws.taskRegistry.CreateTask(taskType, taskParams)
if err != nil {
return fmt.Errorf("failed to create task instance: %v", err)
}
// Update progress to show task has started
mws.updateTaskProgress(task.ID, 5)
// Execute the task
err = taskInstance.Execute(taskParams)
if err != nil {
return fmt.Errorf("task execution failed: %v", err)
}
// Update progress to show completion
mws.updateTaskProgress(task.ID, 100)
glog.V(2).Infof("Generic task %s completed successfully", task.ID)
return nil
}
// initializeTaskExecutors sets up the task execution registry dynamically
func (mws *MaintenanceWorkerService) initializeTaskExecutors() {
mws.taskExecutors = make(map[MaintenanceTaskType]TaskExecutor)
// Get all registered executor factories and create executors
executorRegistryMutex.RLock()
defer executorRegistryMutex.RUnlock()
for taskType, factory := range taskExecutorFactories {
executor := factory()
mws.taskExecutors[taskType] = executor
glog.V(3).Infof("Initialized executor for task type: %s", taskType)
}
glog.V(2).Infof("Initialized %d task executors", len(mws.taskExecutors))
}
// RegisterTaskExecutor allows dynamic registration of new task executors
func (mws *MaintenanceWorkerService) RegisterTaskExecutor(taskType MaintenanceTaskType, executor TaskExecutor) {
if mws.taskExecutors == nil {
mws.taskExecutors = make(map[MaintenanceTaskType]TaskExecutor)
}
mws.taskExecutors[taskType] = executor
glog.V(1).Infof("Registered executor for task type: %s", taskType)
}
// GetSupportedTaskTypes returns all task types that this worker can execute
func (mws *MaintenanceWorkerService) GetSupportedTaskTypes() []MaintenanceTaskType {
return GetSupportedExecutorTaskTypes()
}
// Start begins the worker service
func (mws *MaintenanceWorkerService) Start() error {
mws.running = true
// Register with admin server
worker := &MaintenanceWorker{
ID: mws.workerID,
Address: mws.address,
Capabilities: mws.capabilities,
MaxConcurrent: mws.maxConcurrent,
}
if mws.queue != nil {
mws.queue.RegisterWorker(worker)
}
// Start worker loop
go mws.workerLoop()
glog.Infof("Maintenance worker %s started at %s", mws.workerID, mws.address)
return nil
}
// Stop terminates the worker service
func (mws *MaintenanceWorkerService) Stop() {
mws.running = false
close(mws.stopChan)
// Wait for current tasks to complete or timeout
timeout := time.NewTimer(30 * time.Second)
defer timeout.Stop()
for len(mws.currentTasks) > 0 {
select {
case <-timeout.C:
glog.Warningf("Worker %s stopping with %d tasks still running", mws.workerID, len(mws.currentTasks))
return
case <-time.After(time.Second):
// Check again
}
}
glog.Infof("Maintenance worker %s stopped", mws.workerID)
}
// workerLoop is the main worker event loop
func (mws *MaintenanceWorkerService) workerLoop() {
heartbeatTicker := time.NewTicker(30 * time.Second)
defer heartbeatTicker.Stop()
taskRequestTicker := time.NewTicker(5 * time.Second)
defer taskRequestTicker.Stop()
for mws.running {
select {
case <-mws.stopChan:
return
case <-heartbeatTicker.C:
mws.sendHeartbeat()
case <-taskRequestTicker.C:
mws.requestTasks()
}
}
}
// sendHeartbeat sends heartbeat to admin server
func (mws *MaintenanceWorkerService) sendHeartbeat() {
if mws.queue != nil {
mws.queue.UpdateWorkerHeartbeat(mws.workerID)
}
}
// requestTasks requests new tasks from the admin server
func (mws *MaintenanceWorkerService) requestTasks() {
if len(mws.currentTasks) >= mws.maxConcurrent {
return // Already at capacity
}
if mws.queue != nil {
task := mws.queue.GetNextTask(mws.workerID, mws.capabilities)
if task != nil {
mws.executeTask(task)
}
}
}
// executeTask executes a maintenance task
func (mws *MaintenanceWorkerService) executeTask(task *MaintenanceTask) {
mws.currentTasks[task.ID] = task
go func() {
defer func() {
delete(mws.currentTasks, task.ID)
}()
glog.Infof("Worker %s executing task %s: %s", mws.workerID, task.ID, task.Type)
// Execute task using dynamic executor registry
var err error
if executor, exists := mws.taskExecutors[task.Type]; exists {
err = executor(mws, task)
} else {
err = fmt.Errorf("unsupported task type: %s", task.Type)
glog.Errorf("No executor registered for task type: %s", task.Type)
}
// Report task completion
if mws.queue != nil {
errorMsg := ""
if err != nil {
errorMsg = err.Error()
}
mws.queue.CompleteTask(task.ID, errorMsg)
}
if err != nil {
glog.Errorf("Worker %s failed to execute task %s: %v", mws.workerID, task.ID, err)
} else {
glog.Infof("Worker %s completed task %s successfully", mws.workerID, task.ID)
}
}()
}
// updateTaskProgress updates the progress of a task
func (mws *MaintenanceWorkerService) updateTaskProgress(taskID string, progress float64) {
if mws.queue != nil {
mws.queue.UpdateTaskProgress(taskID, progress)
}
}
// GetStatus returns the current status of the worker
func (mws *MaintenanceWorkerService) GetStatus() map[string]interface{} {
return map[string]interface{}{
"worker_id": mws.workerID,
"address": mws.address,
"running": mws.running,
"capabilities": mws.capabilities,
"max_concurrent": mws.maxConcurrent,
"current_tasks": len(mws.currentTasks),
"task_details": mws.currentTasks,
}
}
// SetQueue sets the maintenance queue for the worker
func (mws *MaintenanceWorkerService) SetQueue(queue *MaintenanceQueue) {
mws.queue = queue
}
// SetAdminClient sets the admin client for the worker
func (mws *MaintenanceWorkerService) SetAdminClient(client AdminClient) {
mws.adminClient = client
}
// SetCapabilities sets the worker capabilities
func (mws *MaintenanceWorkerService) SetCapabilities(capabilities []MaintenanceTaskType) {
mws.capabilities = capabilities
}
// SetMaxConcurrent sets the maximum concurrent tasks
func (mws *MaintenanceWorkerService) SetMaxConcurrent(max int) {
mws.maxConcurrent = max
}
// SetHeartbeatInterval sets the heartbeat interval (placeholder for future use)
func (mws *MaintenanceWorkerService) SetHeartbeatInterval(interval time.Duration) {
// Future implementation for configurable heartbeat
}
// SetTaskRequestInterval sets the task request interval (placeholder for future use)
func (mws *MaintenanceWorkerService) SetTaskRequestInterval(interval time.Duration) {
// Future implementation for configurable task requests
}
// MaintenanceWorkerCommand represents a standalone maintenance worker command
type MaintenanceWorkerCommand struct {
workerService *MaintenanceWorkerService
}
// NewMaintenanceWorkerCommand creates a new worker command
func NewMaintenanceWorkerCommand(workerID, address, adminServer string) *MaintenanceWorkerCommand {
return &MaintenanceWorkerCommand{
workerService: NewMaintenanceWorkerService(workerID, address, adminServer),
}
}
// Run starts the maintenance worker as a standalone service
func (mwc *MaintenanceWorkerCommand) Run() error {
// Generate worker ID if not provided
if mwc.workerService.workerID == "" {
hostname, _ := os.Hostname()
mwc.workerService.workerID = fmt.Sprintf("worker-%s-%d", hostname, time.Now().Unix())
}
// Start the worker service
err := mwc.workerService.Start()
if err != nil {
return fmt.Errorf("failed to start maintenance worker: %v", err)
}
// Wait for interrupt signal
select {}
}
+217
View File
@@ -0,0 +1,217 @@
/* SeaweedFS Dashboard Custom Styles */
/* Sidebar Styles */
.sidebar {
position: fixed;
top: 56px;
bottom: 0;
left: 0;
z-index: 100;
padding: 48px 0 0;
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
.sidebar-heading {
font-size: .75rem;
text-transform: uppercase;
}
.sidebar .nav-link {
font-weight: 500;
color: #333;
}
.sidebar .nav-link:hover {
color: #007bff;
}
.sidebar .nav-link.active {
color: #007bff;
}
.sidebar .nav-link:hover .feather,
.sidebar .nav-link.active .feather {
color: inherit;
}
/* Main content area */
main {
margin-left: 240px;
}
@media (max-width: 767.98px) {
.sidebar {
top: 5rem;
}
main {
margin-left: 0;
}
}
/* Custom card styles */
.border-left-primary {
border-left: 0.25rem solid #4e73df !important;
}
.border-left-success {
border-left: 0.25rem solid #1cc88a !important;
}
.border-left-info {
border-left: 0.25rem solid #36b9cc !important;
}
.border-left-warning {
border-left: 0.25rem solid #f6c23e !important;
}
.border-left-danger {
border-left: 0.25rem solid #e74a3b !important;
}
/* Status badges */
.badge {
font-size: 0.875em;
}
/* Progress bars */
.progress {
background-color: #f8f9fc;
border: 1px solid #e3e6f0;
}
.progress-bar {
font-size: 0.75rem;
font-weight: 700;
color: #fff;
text-align: center;
}
/* Tables */
.table {
color: #5a5c69;
}
.table thead th {
vertical-align: bottom;
border-bottom: 1px solid #e3e6f0;
font-weight: 700;
color: #5a5c69;
background-color: #f8f9fc;
}
.table-bordered {
border: 1px solid #e3e6f0;
}
.table-bordered th,
.table-bordered td {
border: 1px solid #e3e6f0;
}
/* Cards */
.card {
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
border: 1px solid #e3e6f0;
}
.card-header {
background-color: #f8f9fc;
border-bottom: 1px solid #e3e6f0;
}
/* Buttons */
.btn-primary {
background-color: #4e73df;
border-color: #4e73df;
}
.btn-primary:hover {
background-color: #2e59d9;
border-color: #2653d4;
}
/* Text utilities */
.text-gray-800 {
color: #5a5c69 !important;
}
.text-gray-300 {
color: #dddfeb !important;
}
/* Animation for HTMX updates */
.htmx-indicator {
opacity: 0;
transition: opacity 500ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
.htmx-request.htmx-indicator {
opacity: 1;
}
/* Loading spinner */
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
/* Custom utilities */
.bg-gradient-primary {
background: linear-gradient(180deg, #4e73df 10%, #224abe 100%);
}
.shadow {
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
}
/* Collapsible menu styles */
.nav-link[data-bs-toggle="collapse"] {
position: relative;
}
.nav-link[data-bs-toggle="collapse"] .fa-chevron-down {
transition: transform 0.2s ease;
}
.nav-link[data-bs-toggle="collapse"][aria-expanded="true"] .fa-chevron-down {
transform: rotate(180deg);
}
.nav-link[data-bs-toggle="collapse"]:not(.collapsed) {
color: #007bff;
}
.nav-link[data-bs-toggle="collapse"]:not(.collapsed) .fa-chevron-down {
color: #007bff;
}
/* Submenu styles */
.nav .nav {
border-left: 1px solid #e3e6f0;
margin-left: 0.5rem;
}
.nav .nav .nav-link {
font-size: 0.875rem;
padding-left: 1rem;
}
.nav .nav .nav-link:hover {
background-color: #f8f9fc;
}
/* Responsive adjustments */
@media (max-width: 576px) {
.card-body {
padding: 1rem;
}
.h5 {
font-size: 1rem;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because it is too large Load Diff
+306
View File
@@ -0,0 +1,306 @@
package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ Admin(data dash.AdminData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">
<i class="fas fa-tachometer-alt me-2"></i>Dashboard
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<a href="/s3/buckets" class="btn btn-sm btn-primary">
<i class="fas fa-cube me-1"></i>Object Store Buckets
</a>
</div>
</div>
</div>
<div id="dashboard-content">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
Total Volumes
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalVolumes)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-database fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
Total Files
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{formatNumber(data.TotalFiles)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-file fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Total Size
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{formatBytes(data.TotalSize)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-hdd fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Master Nodes Status -->
<div class="row mb-4">
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-server me-2"></i>Master Nodes
</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th>Address</th>
<th>Role</th>
</tr>
</thead>
<tbody>
for _, master := range data.MasterNodes {
<tr>
<td>{master.Address}</td>
<td>
if master.IsLeader {
<span class="badge bg-primary">Leader</span>
} else {
<span class="badge bg-secondary">Follower</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- System Health -->
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-chart-pie me-2"></i>System Health
</h6>
</div>
<div class="card-body text-center">
<div class="row">
<div class="col-4">
<div class="card bg-light">
<div class="card-body">
<h5>{fmt.Sprintf("%d", len(data.MasterNodes))}</h5>
<small class="text-muted">Masters</small>
</div>
</div>
</div>
<div class="col-4">
<div class="card bg-light">
<div class="card-body">
<h5>{fmt.Sprintf("%d", len(data.VolumeServers))}</h5>
<small class="text-muted">Volume Servers</small>
</div>
</div>
</div>
<div class="col-4">
<div class="card bg-light">
<div class="card-body">
<h5>{fmt.Sprintf("%d", len(data.FilerNodes))}</h5>
<small class="text-muted">Filers</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Volume Servers -->
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-database me-2"></i>Volume Servers
</h6>
<div class="dropdown no-arrow">
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i>
</a>
<div class="dropdown-menu dropdown-menu-right shadow animated--fade-in">
<div class="dropdown-header">Actions:</div>
<a class="dropdown-item" href="/volumes">View Details</a>
<a class="dropdown-item" href="/cluster">Topology View</a>
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" width="100%" cellspacing="0">
<thead>
<tr>
<th>ID</th>
<th>Address</th>
<th>Data Center</th>
<th>Rack</th>
<th>Volumes</th>
<th>Capacity</th>
</tr>
</thead>
<tbody>
for _, vs := range data.VolumeServers {
<tr>
<td>{vs.ID}</td>
<td>
<a href={templ.SafeURL(fmt.Sprintf("http://%s/ui/index.html", vs.PublicURL))} target="_blank">
{vs.Address}
<i class="fas fa-external-link-alt ms-1 text-muted"></i>
</a>
</td>
<td>{vs.DataCenter}</td>
<td>{vs.Rack}</td>
<td>
<div class="progress" style="height: 20px;">
<div class="progress-bar" role="progressbar"
style={fmt.Sprintf("width: %d%%", calculatePercent(vs.Volumes, vs.MaxVolumes))}>
{fmt.Sprintf("%d/%d", vs.Volumes, vs.MaxVolumes)}
</div>
</div>
</td>
<td>{formatBytes(vs.DiskUsage)} / {formatBytes(vs.DiskCapacity)}</td>
</tr>
}
if len(data.VolumeServers) == 0 {
<tr>
<td colspan="6" class="text-center text-muted py-4">
<i class="fas fa-info-circle me-2"></i>
No volume servers found
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Filer Nodes -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-folder me-2"></i>Filer Nodes
</h6>
<div class="dropdown no-arrow">
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i>
</a>
<div class="dropdown-menu dropdown-menu-right shadow animated--fade-in">
<div class="dropdown-header">Actions:</div>
<a class="dropdown-item" href="/filer">File Browser</a>
<a class="dropdown-item" href="/cluster">Topology View</a>
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" width="100%" cellspacing="0">
<thead>
<tr>
<th>Address</th>
<th>Data Center</th>
<th>Rack</th>
<th>Last Updated</th>
</tr>
</thead>
<tbody>
for _, filer := range data.FilerNodes {
<tr>
<td>
<a href={templ.SafeURL(fmt.Sprintf("http://%s", filer.Address))} target="_blank">
{filer.Address}
<i class="fas fa-external-link-alt ms-1 text-muted"></i>
</a>
</td>
<td>{filer.DataCenter}</td>
<td>{filer.Rack}</td>
<td>{filer.LastUpdated.Format("2006-01-02 15:04:05")}</td>
</tr>
}
if len(data.FilerNodes) == 0 {
<tr>
<td colspan="4" class="text-center text-muted py-4">
<i class="fas fa-info-circle me-2"></i>
No filer nodes found
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}
</small>
</div>
</div>
</div>
}
+380
View File
@@ -0,0 +1,380 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
func Admin(data dash.AdminData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><a href=\"/s3/buckets\" class=\"btn btn-sm btn-primary\"><i class=\"fas fa-cube me-1\"></i>Object Store Buckets</a></div></div></div><div id=\"dashboard-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-success shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-success text-uppercase mb-1\">Total Volumes</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalVolumes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 35, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-database fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Total Files</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(formatNumber(data.TotalFiles))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 55, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-file fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-warning shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-warning text-uppercase mb-1\">Total Size</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(data.TotalSize))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 75, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-hdd fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Master Nodes Status --><div class=\"row mb-4\"><div class=\"col-lg-6\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-server me-2\"></i>Master Nodes</h6></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-bordered\" width=\"100%\" cellspacing=\"0\"><thead><tr><th>Address</th><th>Role</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, master := range data.MasterNodes {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(master.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 108, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if master.IsLeader {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"badge bg-primary\">Leader</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge bg-secondary\">Follower</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</tbody></table></div></div></div></div><!-- System Health --><div class=\"col-lg-6\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-chart-pie me-2\"></i>System Health</h6></div><div class=\"card-body text-center\"><div class=\"row\"><div class=\"col-4\"><div class=\"card bg-light\"><div class=\"card-body\"><h5>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.MasterNodes)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 138, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</h5><small class=\"text-muted\">Masters</small></div></div></div><div class=\"col-4\"><div class=\"card bg-light\"><div class=\"card-body\"><h5>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.VolumeServers)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 146, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h5><small class=\"text-muted\">Volume Servers</small></div></div></div><div class=\"col-4\"><div class=\"card bg-light\"><div class=\"card-body\"><h5>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.FilerNodes)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 154, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h5><small class=\"text-muted\">Filers</small></div></div></div></div></div></div></div></div><!-- Volume Servers --><div class=\"row\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex flex-row align-items-center justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-database me-2\"></i>Volume Servers</h6><div class=\"dropdown no-arrow\"><a class=\"dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-ellipsis-v fa-sm fa-fw text-gray-400\"></i></a><div class=\"dropdown-menu dropdown-menu-right shadow animated--fade-in\"><div class=\"dropdown-header\">Actions:</div><a class=\"dropdown-item\" href=\"/volumes\">View Details</a> <a class=\"dropdown-item\" href=\"/cluster\">Topology View</a></div></div></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-hover\" width=\"100%\" cellspacing=\"0\"><thead><tr><th>ID</th><th>Address</th><th>Data Center</th><th>Rack</th><th>Volumes</th><th>Capacity</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, vs := range data.VolumeServers {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(vs.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 200, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.SafeURL = templ.SafeURL(fmt.Sprintf("http://%s/ui/index.html", vs.PublicURL))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var10)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" target=\"_blank\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(vs.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 203, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " <i class=\"fas fa-external-link-alt ms-1 text-muted\"></i></a></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(vs.DataCenter)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 207, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(vs.Rack)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 208, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td><div class=\"progress\" style=\"height: 20px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%", calculatePercent(vs.Volumes, vs.MaxVolumes)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 212, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", vs.Volumes, vs.MaxVolumes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 213, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></div></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(vs.DiskUsage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 217, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " / ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(vs.DiskCapacity))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 217, Col: 107}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(data.VolumeServers) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<tr><td colspan=\"6\" class=\"text-center text-muted py-4\"><i class=\"fas fa-info-circle me-2\"></i> No volume servers found</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</tbody></table></div></div></div></div></div><!-- Filer Nodes --><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex flex-row align-items-center justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-folder me-2\"></i>Filer Nodes</h6><div class=\"dropdown no-arrow\"><a class=\"dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-ellipsis-v fa-sm fa-fw text-gray-400\"></i></a><div class=\"dropdown-menu dropdown-menu-right shadow animated--fade-in\"><div class=\"dropdown-header\">Actions:</div><a class=\"dropdown-item\" href=\"/filer\">File Browser</a> <a class=\"dropdown-item\" href=\"/cluster\">Topology View</a></div></div></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-hover\" width=\"100%\" cellspacing=\"0\"><thead><tr><th>Address</th><th>Data Center</th><th>Rack</th><th>Last Updated</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, filer := range data.FilerNodes {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<tr><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 templ.SafeURL = templ.SafeURL(fmt.Sprintf("http://%s", filer.Address))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var18)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" target=\"_blank\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(filer.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 271, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " <i class=\"fas fa-external-link-alt ms-1 text-muted\"></i></a></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(filer.DataCenter)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 275, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(filer.Rack)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 276, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(filer.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 277, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(data.FilerNodes) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<tr><td colspan=\"4\" class=\"text-center text-muted py-4\"><i class=\"fas fa-info-circle me-2\"></i> No filer nodes found</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</tbody></table></div></div></div></div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 301, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</small></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,265 @@
package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ ClusterCollections(data dash.ClusterCollectionsData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">
<i class="fas fa-layer-group me-2"></i>Cluster Collections
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="exportCollections()">
<i class="fas fa-download me-1"></i>Export
</button>
</div>
</div>
</div>
<div id="collections-content">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Collections
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalCollections)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-layer-group fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
Total Volumes
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalVolumes)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-database fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Total Files
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalFiles)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-file fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-secondary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-secondary text-uppercase mb-1">
Total Storage Size
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{formatBytes(data.TotalSize)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-hdd fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Collections Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-layer-group me-2"></i>Collection Details
</h6>
</div>
<div class="card-body">
if len(data.Collections) > 0 {
<div class="table-responsive">
<table class="table table-hover" id="collectionsTable">
<thead>
<tr>
<th>Collection Name</th>
<th>Volumes</th>
<th>Files</th>
<th>Size</th>
<th>Disk Types</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, collection := range data.Collections {
<tr>
<td>
<a href={templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", collection.Name))} class="text-decoration-none">
<strong>{collection.Name}</strong>
</a>
</td>
<td>
<a href={templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", collection.Name))} class="text-decoration-none">
<div class="d-flex align-items-center">
<i class="fas fa-database me-2 text-muted"></i>
{fmt.Sprintf("%d", collection.VolumeCount)}
</div>
</a>
</td>
<td>
<div class="d-flex align-items-center">
<i class="fas fa-file me-2 text-muted"></i>
{fmt.Sprintf("%d", collection.FileCount)}
</div>
</td>
<td>
<div class="d-flex align-items-center">
<i class="fas fa-hdd me-2 text-muted"></i>
{formatBytes(collection.TotalSize)}
</div>
</td>
<td>
for i, diskType := range collection.DiskTypes {
if i > 0 {
<span class="me-1"></span>
}
<span class={fmt.Sprintf("badge bg-%s me-1", getDiskTypeColor(diskType))}>{diskType}</span>
}
if len(collection.DiskTypes) == 0 {
<span class="text-muted">Unknown</span>
}
</td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary btn-sm"
title="View Details">
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
title="Edit">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
title="Delete"
data-collection-name={collection.Name}
onclick="confirmDeleteCollection(this)">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
} else {
<div class="text-center py-5">
<i class="fas fa-layer-group fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Collections Found</h5>
<p class="text-muted">No collections are currently configured in the cluster.</p>
</div>
}
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}
</small>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteCollectionModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Delete Collection
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the collection <strong id="deleteCollectionName"></strong>?</p>
<div class="alert alert-warning">
<i class="fas fa-warning me-2"></i>
This action cannot be undone. All volumes in this collection will be affected.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteCollection">Delete Collection</button>
</div>
</div>
</div>
</div>
}
func getDiskTypeColor(diskType string) string {
switch diskType {
case "ssd":
return "primary"
case "hdd", "":
return "secondary"
default:
return "info"
}
}
func formatDiskTypes(diskTypes []string) string {
if len(diskTypes) == 0 {
return "Unknown"
}
if len(diskTypes) == 1 {
return diskTypes[0]
}
// For multiple disk types, join with comma
result := ""
for i, diskType := range diskTypes {
if i > 0 {
result += ", "
}
result += diskType
}
return result
}
@@ -0,0 +1,304 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-layer-group me-2\"></i>Cluster Collections</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-outline-primary\" onclick=\"exportCollections()\"><i class=\"fas fa-download me-1\"></i>Export</button></div></div></div><div id=\"collections-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Collections</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalCollections))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 34, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-layer-group fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Total Volumes</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalVolumes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 54, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-database fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-warning shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-warning text-uppercase mb-1\">Total Files</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalFiles))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 74, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-file fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-secondary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-secondary text-uppercase mb-1\">Total Storage Size</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(data.TotalSize))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 94, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div><div class=\"col-auto\"><i class=\"fas fa-hdd fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Collections Table --><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-layer-group me-2\"></i>Collection Details</h6></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Collections) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"table-responsive\"><table class=\"table table-hover\" id=\"collectionsTable\"><thead><tr><th>Collection Name</th><th>Volumes</th><th>Files</th><th>Size</th><th>Disk Types</th><th>Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, collection := range data.Collections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<tr><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", collection.Name))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var6)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"text-decoration-none\"><strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 132, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</strong></a></td><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", collection.Name))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var8)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"text-decoration-none\"><div class=\"d-flex align-items-center\"><i class=\"fas fa-database me-2 text-muted\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.VolumeCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 139, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></a></td><td><div class=\"d-flex align-items-center\"><i class=\"fas fa-file me-2 text-muted\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.FileCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 146, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></td><td><div class=\"d-flex align-items-center\"><i class=\"fas fa-hdd me-2 text-muted\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(collection.TotalSize))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 152, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for i, diskType := range collection.DiskTypes {
if i > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"me-1\"></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 = []any{fmt.Sprintf("badge bg-%s me-1", getDiskTypeColor(diskType))}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var12).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(diskType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 160, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(collection.DiskTypes) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"text-muted\">Unknown</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Edit\"><i class=\"fas fa-edit\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-collection-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 178, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" onclick=\"confirmDeleteCollection(this)\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"text-center py-5\"><i class=\"fas fa-layer-group fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Collections Found</h5><p class=\"text-muted\">No collections are currently configured in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 204, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</small></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteCollectionModal\" tabindex=\"-1\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title text-danger\"><i class=\"fas fa-exclamation-triangle me-2\"></i>Delete Collection</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the collection <strong id=\"deleteCollectionName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-warning me-2\"></i> This action cannot be undone. All volumes in this collection will be affected.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" id=\"confirmDeleteCollection\">Delete Collection</button></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func getDiskTypeColor(diskType string) string {
switch diskType {
case "ssd":
return "primary"
case "hdd", "":
return "secondary"
default:
return "info"
}
}
func formatDiskTypes(diskTypes []string) string {
if len(diskTypes) == 0 {
return "Unknown"
}
if len(diskTypes) == 1 {
return diskTypes[0]
}
// For multiple disk types, join with comma
result := ""
for i, diskType := range diskTypes {
if i > 0 {
result += ", "
}
result += diskType
}
return result
}
var _ = templruntime.GeneratedTemplate
+129
View File
@@ -0,0 +1,129 @@
package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ ClusterFilers(data dash.ClusterFilersData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">
<i class="fas fa-folder-open me-2"></i>Filers
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="exportFilers()">
<i class="fas fa-download me-1"></i>Export
</button>
</div>
</div>
</div>
<div id="filers-content">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-12 col-md-12 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Filers
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", data.TotalFilers) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-folder-open fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filers Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-folder-open me-2"></i>Filers
</h6>
</div>
<div class="card-body">
if len(data.Filers) > 0 {
<div class="table-responsive">
<table class="table table-hover" id="filersTable">
<thead>
<tr>
<th>Address</th>
<th>Version</th>
<th>Data Center</th>
<th>Rack</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, filer := range data.Filers {
<tr>
<td>
<a href={ templ.SafeURL(fmt.Sprintf("http://%s", filer.Address)) } target="_blank" class="text-decoration-none">
{ filer.Address }
<i class="fas fa-external-link-alt ms-1 text-muted"></i>
</a>
</td>
<td>
<span class="badge bg-light text-dark">{ filer.Version }</span>
</td>
<td>
<span class="badge bg-light text-dark">{ filer.DataCenter }</span>
</td>
<td>
<span class="badge bg-light text-dark">{ filer.Rack }</span>
</td>
<td>
if !filer.CreatedAt.IsZero() {
{ filer.CreatedAt.Format("2006-01-02 15:04:05") }
} else {
<span class="text-muted">N/A</span>
}
</td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary btn-sm" title="View Details">
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" title="File Browser" onclick={ templ.ComponentScript{Call: fmt.Sprintf("window.open('http://%s', '_blank')", filer.Address)} }>
<i class="fas fa-folder-open"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
} else {
<div class="text-center py-5">
<i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Filers Found</h5>
<p class="text-muted">No filer servers are currently available in the cluster.</p>
</div>
}
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: { data.LastUpdated.Format("2006-01-02 15:04:05") }
</small>
</div>
</div>
</div>
}
+194
View File
@@ -0,0 +1,194 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
func ClusterFilers(data dash.ClusterFilersData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-folder-open me-2\"></i>Filers</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-outline-primary\" onclick=\"exportFilers()\"><i class=\"fas fa-download me-1\"></i>Export</button></div></div></div><div id=\"filers-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-12 col-md-12 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Filers</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalFilers))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_filers.templ`, Line: 34, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-folder-open fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Filers Table --><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-folder-open me-2\"></i>Filers</h6></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Filers) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"table-responsive\"><table class=\"table table-hover\" id=\"filersTable\"><thead><tr><th>Address</th><th>Version</th><th>Data Center</th><th>Rack</th><th>Created At</th><th>Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, filer := range data.Filers {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<tr><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL = templ.SafeURL(fmt.Sprintf("http://%s", filer.Address))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" target=\"_blank\" class=\"text-decoration-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(filer.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_filers.templ`, Line: 72, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " <i class=\"fas fa-external-link-alt ms-1 text-muted\"></i></a></td><td><span class=\"badge bg-light text-dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filer.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_filers.templ`, Line: 77, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span></td><td><span class=\"badge bg-light text-dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(filer.DataCenter)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_filers.templ`, Line: 80, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></td><td><span class=\"badge bg-light text-dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(filer.Rack)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_filers.templ`, Line: 83, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !filer.CreatedAt.IsZero() {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(filer.CreatedAt.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_filers.templ`, Line: 87, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span class=\"text-muted\">N/A</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("window.open('http://%s', '_blank')", filer.Address)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"File Browser\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("window.open('http://%s', '_blank')", filer.Address)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><i class=\"fas fa-folder-open\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"text-center py-5\"><i class=\"fas fa-folder-open fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Filers Found</h5><p class=\"text-muted\">No filer servers are currently available in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_filers.templ`, Line: 122, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</small></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+175
View File
@@ -0,0 +1,175 @@
package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ ClusterMasters(data dash.ClusterMastersData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">
<i class="fas fa-crown me-2"></i>Masters
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="exportMasters()">
<i class="fas fa-download me-1"></i>Export
</button>
</div>
</div>
</div>
<div id="masters-content">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Masters
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", data.TotalMasters) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-crown fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
Leaders
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", data.LeaderCount) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-star fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Cluster Health
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.LeaderCount > 0 {
Healthy
} else {
Warning
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-heartbeat fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Masters Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-crown me-2"></i>Masters
</h6>
</div>
<div class="card-body">
if len(data.Masters) > 0 {
<div class="table-responsive">
<table class="table table-hover" id="mastersTable">
<thead>
<tr>
<th>Address</th>
<th>Role</th>
<th>Suffrage</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, master := range data.Masters {
<tr>
<td>
<a href={ templ.SafeURL(fmt.Sprintf("http://%s", master.Address)) } target="_blank" class="text-decoration-none">
{ master.Address }
<i class="fas fa-external-link-alt ms-1 text-muted"></i>
</a>
</td>
<td>
if master.IsLeader {
<span class="badge bg-warning text-dark">
<i class="fas fa-star me-1"></i>Leader
</span>
} else {
<span class="badge bg-secondary">
<i class="fas fa-circle me-1"></i>Follower
</span>
}
</td>
<td>
if master.Suffrage != "" {
<span class="badge bg-info text-dark">
{ master.Suffrage }
</span>
} else {
<span class="text-muted">-</span>
}
</td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary btn-sm" title="View Details">
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" title="Manage">
<i class="fas fa-cog"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
} else {
<div class="text-center py-5">
<i class="fas fa-crown fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Masters Found</h5>
<p class="text-muted">No master servers are currently available in the cluster.</p>
</div>
}
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: { data.LastUpdated.Format("2006-01-02 15:04:05") }
</small>
</div>
</div>
</div>
}
@@ -0,0 +1,189 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
func ClusterMasters(data dash.ClusterMastersData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-crown me-2\"></i>Masters</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-outline-primary\" onclick=\"exportMasters()\"><i class=\"fas fa-download me-1\"></i>Export</button></div></div></div><div id=\"masters-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Masters</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalMasters))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 34, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-crown fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Leaders</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.LeaderCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 54, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-star fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-warning shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-warning text-uppercase mb-1\">Cluster Health</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.LeaderCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Healthy")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Warning")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div></div><div class=\"col-auto\"><i class=\"fas fa-heartbeat fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Masters Table --><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-crown me-2\"></i>Masters</h6></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Masters) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"table-responsive\"><table class=\"table table-hover\" id=\"mastersTable\"><thead><tr><th>Address</th><th>Role</th><th>Suffrage</th><th>Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, master := range data.Masters {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<tr><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL = templ.SafeURL(fmt.Sprintf("http://%s", master.Address))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" target=\"_blank\" class=\"text-decoration-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(master.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 114, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " <i class=\"fas fa-external-link-alt ms-1 text-muted\"></i></a></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if master.IsLeader {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"badge bg-warning text-dark\"><i class=\"fas fa-star me-1\"></i>Leader</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"badge bg-secondary\"><i class=\"fas fa-circle me-1\"></i>Follower</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if master.Suffrage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"badge bg-info text-dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(master.Suffrage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 132, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"text-muted\">-</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Manage\"><i class=\"fas fa-cog\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"text-center py-5\"><i class=\"fas fa-crown fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Masters Found</h5><p class=\"text-muted\">No master servers are currently available in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 168, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</small></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,189 @@
package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ ClusterVolumeServers(data dash.ClusterVolumeServersData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">
<i class="fas fa-server me-2"></i>Volume Servers
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="exportVolumeServers()">
<i class="fas fa-download me-1"></i>Export
</button>
</div>
</div>
</div>
<div id="hosts-content">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Volume Servers
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalVolumeServers)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-server fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
Total Volumes
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalVolumes)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-database fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Total Capacity
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{formatBytes(data.TotalCapacity)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-hdd fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Hosts Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-server me-2"></i>Volume Servers
</h6>
</div>
<div class="card-body">
if len(data.VolumeServers) > 0 {
<div class="table-responsive">
<table class="table table-hover" id="hostsTable">
<thead>
<tr>
<th>Server ID</th>
<th>Address</th>
<th>Data Center</th>
<th>Rack</th>
<th>Volumes</th>
<th>Capacity</th>
<th>Usage</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, host := range data.VolumeServers {
<tr>
<td>
<code>{host.ID}</code>
</td>
<td>
<a href={templ.SafeURL(fmt.Sprintf("http://%s/ui/index.html", host.PublicURL))} target="_blank" class="text-decoration-none">
{host.Address}
<i class="fas fa-external-link-alt ms-1 text-muted"></i>
</a>
</td>
<td>
<span class="badge bg-light text-dark">{host.DataCenter}</span>
</td>
<td>
<span class="badge bg-light text-dark">{host.Rack}</span>
</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 60px; height: 16px;">
<div class="progress-bar" role="progressbar"
style={fmt.Sprintf("width: %d%%", calculatePercent(host.Volumes, host.MaxVolumes))}>
</div>
</div>
<small>{fmt.Sprintf("%d/%d", host.Volumes, host.MaxVolumes)}</small>
</div>
</td>
<td>{formatBytes(host.DiskCapacity)}</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 60px; height: 16px;">
<div class="progress-bar" role="progressbar"
style={fmt.Sprintf("width: %d%%", calculatePercent(int(host.DiskUsage), int(host.DiskCapacity)))}>
</div>
</div>
<small>{formatBytes(host.DiskUsage)}</small>
</div>
</td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary btn-sm"
title="View Details">
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
title="Manage">
<i class="fas fa-cog"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
} else {
<div class="text-center py-5">
<i class="fas fa-server fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Volume Servers Found</h5>
<p class="text-muted">No volume servers are currently available in the cluster.</p>
</div>
}
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}
</small>
</div>
</div>
</div>
}
@@ -0,0 +1,248 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
func ClusterVolumeServers(data dash.ClusterVolumeServersData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-server me-2\"></i>Volume Servers</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-outline-primary\" onclick=\"exportVolumeServers()\"><i class=\"fas fa-download me-1\"></i>Export</button></div></div></div><div id=\"hosts-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Volume Servers</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalVolumeServers))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 34, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-server fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Total Volumes</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalVolumes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 56, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-database fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-warning shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-warning text-uppercase mb-1\">Total Capacity</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(data.TotalCapacity))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 76, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-hdd fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Hosts Table --><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-server me-2\"></i>Volume Servers</h6></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.VolumeServers) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"table-responsive\"><table class=\"table table-hover\" id=\"hostsTable\"><thead><tr><th>Server ID</th><th>Address</th><th>Data Center</th><th>Rack</th><th>Volumes</th><th>Capacity</th><th>Usage</th><th>Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, host := range data.VolumeServers {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<tr><td><code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(host.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 115, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</code></td><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL = templ.SafeURL(fmt.Sprintf("http://%s/ui/index.html", host.PublicURL))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var6)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" target=\"_blank\" class=\"text-decoration-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(host.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 119, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " <i class=\"fas fa-external-link-alt ms-1 text-muted\"></i></a></td><td><span class=\"badge bg-light text-dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(host.DataCenter)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 124, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></td><td><span class=\"badge bg-light text-dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(host.Rack)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 127, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span></td><td><div class=\"d-flex align-items-center\"><div class=\"progress me-2\" style=\"width: 60px; height: 16px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%", calculatePercent(host.Volumes, host.MaxVolumes)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 133, Col: 139}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></div></div><small>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", host.Volumes, host.MaxVolumes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 136, Col: 107}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</small></div></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(host.DiskCapacity))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 139, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</td><td><div class=\"d-flex align-items-center\"><div class=\"progress me-2\" style=\"width: 60px; height: 16px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%", calculatePercent(int(host.DiskUsage), int(host.DiskCapacity))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 144, Col: 153}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"></div></div><small>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(host.DiskUsage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 147, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Manage\"><i class=\"fas fa-cog\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"text-center py-5\"><i class=\"fas fa-server fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Volume Servers Found</h5><p class=\"text-muted\">No volume servers are currently available in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 182, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</small></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+726
View File
@@ -0,0 +1,726 @@
package app
import (
"fmt"
"strings"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ ClusterVolumes(data dash.ClusterVolumesData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<div>
<h1 class="h2">
<i class="fas fa-database me-2"></i>Cluster Volumes
</h1>
if data.FilterCollection != "" {
<div class="d-flex align-items-center mt-2">
<span class="badge bg-info me-2">
<i class="fas fa-filter me-1"></i>Collection: {data.FilterCollection}
</span>
<a href="/cluster/volumes" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-times me-1"></i>Clear Filter
</a>
</div>
}
</div>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<select class="form-select form-select-sm me-2" id="pageSizeSelect" onchange="changePageSize()" style="width: auto;">
<option value="50" if data.PageSize == 50 { selected="selected" }>50 per page</option>
<option value="100" if data.PageSize == 100 { selected="selected" }>100 per page</option>
<option value="200" if data.PageSize == 200 { selected="selected" }>200 per page</option>
<option value="500" if data.PageSize == 500 { selected="selected" }>500 per page</option>
</select>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="exportVolumes()">
<i class="fas fa-download me-1"></i>Export
</button>
</div>
</div>
</div>
<div id="volumes-content">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-2 col-md-4 col-sm-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Volumes
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalVolumes)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-database fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
if data.CollectionCount == 1 {
Collection
} else {
Collections
}
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.CollectionCount == 1 {
{data.SingleCollection}
} else {
{fmt.Sprintf("%d", data.CollectionCount)}
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-layer-group fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
if data.DataCenterCount == 1 {
Data Center
} else {
Data Centers
}
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.DataCenterCount == 1 {
{data.SingleDataCenter}
} else {
{fmt.Sprintf("%d", data.DataCenterCount)}
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-building fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-4">
<div class="card border-left-secondary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-secondary text-uppercase mb-1">
if data.RackCount == 1 {
Rack
} else {
Racks
}
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.RackCount == 1 {
{data.SingleRack}
} else {
{fmt.Sprintf("%d", data.RackCount)}
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-server fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-4">
<div class="card border-left-dark shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-dark text-uppercase mb-1">
if data.DiskTypeCount == 1 {
Disk Type
} else {
Disk Types
}
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.DiskTypeCount == 1 {
{data.SingleDiskType}
} else {
{strings.Join(data.AllDiskTypes, ", ")}
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-hdd fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-4">
<div class="card border-left-purple shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-purple text-uppercase mb-1">
if data.VersionCount == 1 {
Version
} else {
Versions
}
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.VersionCount == 1 {
{data.SingleVersion}
} else {
{strings.Join(data.AllVersions, ", ")}
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-code-branch fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Total Size
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{formatBytes(data.TotalSize)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-chart-area fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Volumes Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-database me-2"></i>Volume Details
</h6>
</div>
<div class="card-body">
if len(data.Volumes) > 0 {
<div class="table-responsive">
<table class="table table-hover" id="volumesTable">
<thead>
<tr>
<th>
<a href="#" onclick="sortTable('id')" class="text-decoration-none text-dark">
Volume ID
@getSortIcon("id", data.SortBy, data.SortOrder)
</a>
</th>
<th>
<a href="#" onclick="sortTable('server')" class="text-decoration-none text-dark">
Server
@getSortIcon("server", data.SortBy, data.SortOrder)
</a>
</th>
if data.ShowDataCenterColumn {
<th>
<a href="#" onclick="sortTable('datacenter')" class="text-decoration-none text-dark">
Data Center
@getSortIcon("datacenter", data.SortBy, data.SortOrder)
</a>
</th>
}
if data.ShowRackColumn {
<th>
<a href="#" onclick="sortTable('rack')" class="text-decoration-none text-dark">
Rack
@getSortIcon("rack", data.SortBy, data.SortOrder)
</a>
</th>
}
if data.ShowCollectionColumn {
<th>
<a href="#" onclick="sortTable('collection')" class="text-decoration-none text-dark">
Collection
@getSortIcon("collection", data.SortBy, data.SortOrder)
</a>
</th>
}
<th>
<a href="#" onclick="sortTable('size')" class="text-decoration-none text-dark">
Size
@getSortIcon("size", data.SortBy, data.SortOrder)
</a>
</th>
<th>Storage Usage</th>
<th>
<a href="#" onclick="sortTable('filecount')" class="text-decoration-none text-dark">
File Count
@getSortIcon("filecount", data.SortBy, data.SortOrder)
</a>
</th>
<th>
<a href="#" onclick="sortTable('replication')" class="text-decoration-none text-dark">
Replication
@getSortIcon("replication", data.SortBy, data.SortOrder)
</a>
</th>
if data.ShowDiskTypeColumn {
<th>
<a href="#" onclick="sortTable('disktype')" class="text-decoration-none text-dark">
Disk Type
@getSortIcon("disktype", data.SortBy, data.SortOrder)
</a>
</th>
}
if data.ShowVersionColumn {
<th>
<a href="#" onclick="sortTable('version')" class="text-decoration-none text-dark">
Version
@getSortIcon("version", data.SortBy, data.SortOrder)
</a>
</th>
}
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, volume := range data.Volumes {
<tr>
<td>
<code class="volume-id-link" style="cursor: pointer; text-decoration: underline; color: #0d6efd;"
data-volume-id={fmt.Sprintf("%d", volume.Id)}
title="Click to view volume details">
{fmt.Sprintf("%d", volume.Id)}
</code>
</td>
<td>
<a href={templ.SafeURL(fmt.Sprintf("http://%s/ui/index.html", volume.Server))} target="_blank" class="text-decoration-none">
{volume.Server}
<i class="fas fa-external-link-alt ms-1 text-muted"></i>
</a>
</td>
if data.ShowDataCenterColumn {
<td>
<span class="badge bg-light text-dark">{volume.DataCenter}</span>
</td>
}
if data.ShowRackColumn {
<td>
<span class="badge bg-light text-dark">{volume.Rack}</span>
</td>
}
if data.ShowCollectionColumn {
<td>
if volume.Collection == "" {
<a href={templ.SafeURL("/cluster/volumes?collection=default")} class="text-decoration-none">
<span class="badge bg-secondary">default</span>
</a>
} else {
<a href={templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", volume.Collection))} class="text-decoration-none">
<span class="badge bg-secondary">{volume.Collection}</span>
</a>
}
</td>
}
<td>{formatBytes(int64(volume.Size))}</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 80px; height: 16px; background-color: #e9ecef;">
<!-- Active data (green) -->
<div class="progress-bar bg-success" role="progressbar"
style={fmt.Sprintf("width: %.1f%%",
func() float64 {
if volume.Size > 0 {
activePct := float64(volume.Size - volume.DeletedByteCount) / float64(volume.Size) * 100
if data.VolumeSizeLimit > 0 {
return activePct * float64(volume.Size) / float64(data.VolumeSizeLimit) * 100
}
return activePct
}
return 0
}())}
title={fmt.Sprintf("Active: %s", formatBytes(int64(volume.Size - volume.DeletedByteCount)))}>
</div>
<!-- Garbage data (red) -->
<div class="progress-bar bg-danger" role="progressbar"
style={fmt.Sprintf("width: %.1f%%",
func() float64 {
if volume.Size > 0 && volume.DeletedByteCount > 0 {
garbagePct := float64(volume.DeletedByteCount) / float64(volume.Size) * 100
if data.VolumeSizeLimit > 0 {
return garbagePct * float64(volume.Size) / float64(data.VolumeSizeLimit) * 100
}
return garbagePct
}
return 0
}())}
title={fmt.Sprintf("Garbage: %s", formatBytes(int64(volume.DeletedByteCount)))}>
</div>
</div>
<small class="text-muted">
{func() string {
if data.VolumeSizeLimit > 0 {
return fmt.Sprintf("%.0f%%", float64(volume.Size)/float64(data.VolumeSizeLimit)*100)
}
return "N/A"
}()}
</small>
</div>
</td>
<td>{fmt.Sprintf("%d", volume.FileCount)}</td>
<td>
<span class="badge bg-info">{fmt.Sprintf("%03d", volume.ReplicaPlacement)}</span>
</td>
if data.ShowDiskTypeColumn {
<td>
<span class="badge bg-primary">{volume.DiskType}</span>
</td>
}
if data.ShowVersionColumn {
<td>
<span class="badge bg-dark">{fmt.Sprintf("v%d", volume.Version)}</span>
</td>
}
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary btn-sm view-details-btn"
title="View Details" data-volume-id={fmt.Sprintf("%d", volume.Id)}>
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm vacuum-btn"
title="Vacuum"
data-volume-id={fmt.Sprintf("%d", volume.Id)}
data-server={volume.Server}>
<i class="fas fa-compress-alt"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Volume Summary -->
<div class="d-flex justify-content-between align-items-center mt-3">
<div>
<small class="text-muted">
Showing {fmt.Sprintf("%d", (data.CurrentPage-1)*data.PageSize + 1)} to {fmt.Sprintf("%d", minInt(data.CurrentPage*data.PageSize, data.TotalVolumes))} of {fmt.Sprintf("%d", data.TotalVolumes)} volumes
</small>
</div>
if data.TotalPages > 1 {
<div>
<small class="text-muted">
Page {fmt.Sprintf("%d", data.CurrentPage)} of {fmt.Sprintf("%d", data.TotalPages)}
</small>
</div>
}
</div>
<!-- Pagination Controls -->
if data.TotalPages > 1 {
<div class="d-flex justify-content-center mt-3">
<nav aria-label="Volumes pagination">
<ul class="pagination pagination-sm mb-0">
<!-- Previous Button -->
if data.CurrentPage > 1 {
<li class="page-item">
<a class="page-link pagination-link" href="#" data-page={fmt.Sprintf("%d", data.CurrentPage-1)}>
<i class="fas fa-chevron-left"></i>
</a>
</li>
} else {
<li class="page-item disabled">
<span class="page-link">
<i class="fas fa-chevron-left"></i>
</span>
</li>
}
<!-- Page Numbers -->
for i := maxInt(1, data.CurrentPage-2); i <= minInt(data.TotalPages, data.CurrentPage+2); i++ {
if i == data.CurrentPage {
<li class="page-item active">
<span class="page-link">{fmt.Sprintf("%d", i)}</span>
</li>
} else {
<li class="page-item">
<a class="page-link pagination-link" href="#" data-page={fmt.Sprintf("%d", i)}>{fmt.Sprintf("%d", i)}</a>
</li>
}
}
<!-- Next Button -->
if data.CurrentPage < data.TotalPages {
<li class="page-item">
<a class="page-link pagination-link" href="#" data-page={fmt.Sprintf("%d", data.CurrentPage+1)}>
<i class="fas fa-chevron-right"></i>
</a>
</li>
} else {
<li class="page-item disabled">
<span class="page-link">
<i class="fas fa-chevron-right"></i>
</span>
</li>
}
</ul>
</nav>
</div>
}
} else {
<div class="text-center py-5">
<i class="fas fa-database fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Volumes Found</h5>
<p class="text-muted">No volumes are currently available in the cluster.</p>
</div>
}
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}
</small>
</div>
</div>
</div>
<!-- JavaScript for pagination and sorting -->
<script>
// Initialize pagination links when page loads
document.addEventListener('DOMContentLoaded', function() {
// Add click handlers to pagination links
document.querySelectorAll('.pagination-link').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const page = this.getAttribute('data-page');
goToPage(page);
});
});
// Add click handlers to view details buttons
document.querySelectorAll('.view-details-btn').forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const volumeId = this.getAttribute('data-volume-id');
viewVolumeDetails(volumeId);
});
});
// Add click handlers to volume ID links
document.querySelectorAll('.volume-id-link').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const volumeId = this.getAttribute('data-volume-id');
viewVolumeDetails(volumeId);
});
});
// Add click handlers to vacuum buttons
document.querySelectorAll('.vacuum-btn').forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const volumeId = this.getAttribute('data-volume-id');
const server = this.getAttribute('data-server');
performVacuum(volumeId, server, this);
});
});
});
function goToPage(page) {
const url = new URL(window.location);
url.searchParams.set('page', page);
window.location.href = url.toString();
}
function changePageSize() {
const pageSize = document.getElementById('pageSizeSelect').value;
const url = new URL(window.location);
url.searchParams.set('pageSize', pageSize);
url.searchParams.set('page', '1'); // Reset to first page
window.location.href = url.toString();
}
function sortTable(column) {
const url = new URL(window.location);
const currentSort = url.searchParams.get('sortBy');
const currentOrder = url.searchParams.get('sortOrder') || 'asc';
let newOrder = 'asc';
if (currentSort === column && currentOrder === 'asc') {
newOrder = 'desc';
}
url.searchParams.set('sortBy', column);
url.searchParams.set('sortOrder', newOrder);
url.searchParams.set('page', '1'); // Reset to first page
window.location.href = url.toString();
}
function exportVolumes() {
// TODO: Implement volume export functionality
alert('Export functionality to be implemented');
}
function viewVolumeDetails(volumeId) {
// Get the server from the current row - works for both buttons and volume ID links
const clickedElement = event.target;
const row = clickedElement.closest('tr');
const serverCell = row.querySelector('td:nth-child(2) a');
const server = serverCell ? serverCell.textContent.trim() : 'unknown';
window.location.href = `/cluster/volumes/${volumeId}/${encodeURIComponent(server)}`;
}
function performVacuum(volumeId, server, button) {
// Disable button and show loading state
const originalHTML = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
// Send vacuum request
fetch(`/api/volumes/${volumeId}/${encodeURIComponent(server)}/vacuum`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.error) {
showMessage(data.error, 'error');
} else {
showMessage(data.message || 'Volume vacuum started successfully', 'success');
// Optionally refresh the page after a delay to show updated vacuum status
setTimeout(() => {
window.location.reload();
}, 2000);
}
})
.catch(error => {
console.error('Error:', error);
showMessage('Failed to start vacuum operation', 'error');
})
.finally(() => {
// Re-enable button
button.disabled = false;
button.innerHTML = originalHTML;
});
}
function showMessage(message, type) {
// Create toast notification
const toast = document.createElement('div');
toast.className = `alert alert-${type === 'error' ? 'danger' : 'success'} alert-dismissible fade show position-fixed`;
toast.style.top = '20px';
toast.style.right = '20px';
toast.style.zIndex = '9999';
toast.style.minWidth = '300px';
toast.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 5000);
}
</script>
}
func countActiveVolumes(volumes []dash.VolumeWithTopology) int {
// Since we removed status tracking, consider all volumes as active
return len(volumes)
}
func countUniqueDataCenters(volumes []dash.VolumeWithTopology) int {
dcMap := make(map[string]bool)
for _, volume := range volumes {
dcMap[volume.DataCenter] = true
}
return len(dcMap)
}
func countUniqueRacks(volumes []dash.VolumeWithTopology) int {
rackMap := make(map[string]bool)
for _, volume := range volumes {
if volume.Rack != "" {
rackMap[volume.Rack] = true
}
}
return len(rackMap)
}
func countUniqueDiskTypes(volumes []dash.VolumeWithTopology) int {
diskTypeMap := make(map[string]bool)
for _, volume := range volumes {
diskType := volume.DiskType
if diskType == "" {
diskType = "hdd"
}
diskTypeMap[diskType] = true
}
return len(diskTypeMap)
}
templ getSortIcon(column, currentSort, currentOrder string) {
if column != currentSort {
<i class="fas fa-sort text-muted ms-1"></i>
} else if currentOrder == "asc" {
<i class="fas fa-sort-up text-primary ms-1"></i>
} else {
<i class="fas fa-sort-down text-primary ms-1"></i>
}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
File diff suppressed because one or more lines are too long
+438
View File
@@ -0,0 +1,438 @@
package app
import (
"fmt"
"path/filepath"
"strings"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ FileBrowser(data dash.FileBrowserData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">
if data.IsBucketPath && data.BucketName != "" {
<i class="fas fa-cube me-2"></i>S3 Bucket: {data.BucketName}
} else {
<i class="fas fa-folder-open me-2"></i>File Browser
}
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
if data.IsBucketPath && data.BucketName != "" {
<a href="/object-store/buckets" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to Buckets
</a>
}
<button type="button" class="btn btn-sm btn-outline-primary" onclick="createFolder()">
<i class="fas fa-folder-plus me-1"></i>New Folder
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="uploadFile()">
<i class="fas fa-upload me-1"></i>Upload
</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="deleteSelectedBtn" onclick="confirmDeleteSelected()" style="display: none;">
<i class="fas fa-trash me-1"></i>Delete Selected
</button>
<button type="button" class="btn btn-sm btn-outline-info" onclick="exportFileList()">
<i class="fas fa-download me-1"></i>Export
</button>
</div>
</div>
</div>
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
for i, crumb := range data.Breadcrumbs {
if i == len(data.Breadcrumbs)-1 {
<li class="breadcrumb-item active" aria-current="page">
<i class="fas fa-folder me-1"></i>{ crumb.Name }
</li>
} else {
<li class="breadcrumb-item">
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", crumb.Path)) } class="text-decoration-none">
if crumb.Name == "Root" {
<i class="fas fa-home me-1"></i>
} else {
<i class="fas fa-folder me-1"></i>
}
{ crumb.Name }
</a>
</li>
}
}
</ol>
</nav>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Entries
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", data.TotalEntries) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-list fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
Directories
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", countDirectories(data.Entries)) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-folder fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
Files
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", countFiles(data.Entries)) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-file fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Total Size
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ formatBytes(data.TotalSize) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-hdd fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- File Listing -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-folder-open me-2"></i>
if data.CurrentPath == "/" {
Root Directory
} else if data.CurrentPath == "/buckets" {
Object Store Buckets Directory
<a href="/object-store/buckets" class="btn btn-sm btn-outline-primary ms-2">
<i class="fas fa-cube me-1"></i>Manage Buckets
</a>
} else {
{ filepath.Base(data.CurrentPath) }
}
</h6>
if data.ParentPath != data.CurrentPath {
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", data.ParentPath)) } class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-up me-1"></i>Up
</a>
}
</div>
<div class="card-body">
if len(data.Entries) > 0 {
<div class="table-responsive">
<table class="table table-hover" id="fileTable">
<thead>
<tr>
<th width="40px">
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
</th>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Modified</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, entry := range data.Entries {
<tr>
<td>
<input type="checkbox" class="file-checkbox" value={ entry.FullPath }>
</td>
<td>
<div class="d-flex align-items-center">
if entry.IsDirectory {
<i class="fas fa-folder text-warning me-2"></i>
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", entry.FullPath)) } class="text-decoration-none">
{ entry.Name }
</a>
} else {
<i class={ fmt.Sprintf("fas %s text-muted me-2", getFileIcon(entry.Mime)) }></i>
<span>{ entry.Name }</span>
}
</div>
</td>
<td>
if entry.IsDirectory {
<span class="text-muted">—</span>
} else {
{ formatBytes(entry.Size) }
}
</td>
<td>
<span class="badge bg-light text-dark">
if entry.IsDirectory {
Directory
} else {
{ getMimeDisplayName(entry.Mime) }
}
</span>
</td>
<td>
if !entry.ModTime.IsZero() {
{ entry.ModTime.Format("2006-01-02 15:04") }
} else {
<span class="text-muted">—</span>
}
</td>
<td>
<code class="small">{ entry.Mode }</code>
</td>
<td>
<div class="btn-group btn-group-sm">
if !entry.IsDirectory {
<button type="button" class="btn btn-outline-primary btn-sm" title="Download" onclick={ templ.ComponentScript{Call: fmt.Sprintf("downloadFile('%s')", entry.FullPath)} }>
<i class="fas fa-download"></i>
</button>
<button type="button" class="btn btn-outline-info btn-sm" title="View" onclick={ templ.ComponentScript{Call: fmt.Sprintf("viewFile('%s')", entry.FullPath)} }>
<i class="fas fa-eye"></i>
</button>
}
<button type="button" class="btn btn-outline-secondary btn-sm" title="Properties" onclick={ templ.ComponentScript{Call: fmt.Sprintf("showProperties('%s')", entry.FullPath)} }>
<i class="fas fa-info"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Delete" onclick={ templ.ComponentScript{Call: fmt.Sprintf("confirmDelete('%s')", entry.FullPath)} }>
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
} else {
<div class="text-center py-5">
<i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Empty Directory</h5>
<p class="text-muted">This directory contains no files or subdirectories.</p>
</div>
}
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: { data.LastUpdated.Format("2006-01-02 15:04:05") }
</small>
</div>
</div>
<!-- Create Folder Modal -->
<div class="modal fade" id="createFolderModal" tabindex="-1" aria-labelledby="createFolderModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createFolderModalLabel">
<i class="fas fa-folder-plus me-2"></i>Create New Folder
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="createFolderForm">
<div class="mb-3">
<label for="folderName" class="form-label">Folder Name</label>
<input type="text" class="form-control" id="folderName" name="folderName" required
placeholder="Enter folder name" maxlength="255">
<div class="form-text">
Folder names cannot contain / or \ characters.
</div>
</div>
<input type="hidden" id="currentPath" name="currentPath" value={ data.CurrentPath }>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="submitCreateFolder()">
<i class="fas fa-folder-plus me-1"></i>Create Folder
</button>
</div>
</div>
</div>
</div>
<!-- Upload File Modal -->
<div class="modal fade" id="uploadFileModal" tabindex="-1" aria-labelledby="uploadFileModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uploadFileModalLabel">
<i class="fas fa-upload me-2"></i>Upload Files
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="uploadFileForm" enctype="multipart/form-data">
<div class="mb-3">
<label for="fileInput" class="form-label">Select Files</label>
<input type="file" class="form-control" id="fileInput" name="files" multiple required>
<div class="form-text">
Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.
</div>
</div>
<input type="hidden" id="uploadPath" name="path" value={ data.CurrentPath }>
<!-- File List Preview -->
<div id="fileListPreview" class="mb-3" style="display: none;">
<label class="form-label">Selected Files:</label>
<div id="selectedFilesList" class="border rounded p-2 bg-light">
<!-- Files will be listed here -->
</div>
</div>
<!-- Upload Progress -->
<div class="mb-3" id="uploadProgress" style="display: none;">
<label class="form-label">Upload Progress:</label>
<div class="progress mb-2">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
</div>
<div id="uploadStatus" class="small text-muted">
Preparing upload...
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="submitUploadFile()">
<i class="fas fa-upload me-1"></i>Upload Files
</button>
</div>
</div>
</div>
</div>
}
func countDirectories(entries []dash.FileEntry) int {
count := 0
for _, entry := range entries {
if entry.IsDirectory {
count++
}
}
return count
}
func countFiles(entries []dash.FileEntry) int {
count := 0
for _, entry := range entries {
if !entry.IsDirectory {
count++
}
}
return count
}
func getFileIcon(mime string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return "fa-image"
case strings.HasPrefix(mime, "video/"):
return "fa-video"
case strings.HasPrefix(mime, "audio/"):
return "fa-music"
case strings.HasPrefix(mime, "text/"):
return "fa-file-text"
case mime == "application/pdf":
return "fa-file-pdf"
case mime == "application/zip" || strings.Contains(mime, "archive"):
return "fa-file-archive"
case mime == "application/json":
return "fa-file-code"
case strings.Contains(mime, "script") || strings.Contains(mime, "javascript"):
return "fa-file-code"
default:
return "fa-file"
}
}
func getMimeDisplayName(mime string) string {
switch mime {
case "text/plain":
return "Text"
case "text/html":
return "HTML"
case "application/json":
return "JSON"
case "application/pdf":
return "PDF"
case "image/jpeg":
return "JPEG"
case "image/png":
return "PNG"
case "image/gif":
return "GIF"
case "video/mp4":
return "MP4"
case "audio/mpeg":
return "MP3"
case "application/zip":
return "ZIP"
default:
if strings.HasPrefix(mime, "image/") {
return "Image"
} else if strings.HasPrefix(mime, "video/") {
return "Video"
} else if strings.HasPrefix(mime, "audio/") {
return "Audio"
} else if strings.HasPrefix(mime, "text/") {
return "Text"
}
return "File"
}
}
+607
View File
@@ -0,0 +1,607 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"path/filepath"
"strings"
)
func FileBrowser(data dash.FileBrowserData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.IsBucketPath && data.BucketName != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<i class=\"fas fa-cube me-2\"></i>S3 Bucket: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.BucketName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 14, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<i class=\"fas fa-folder-open me-2\"></i>File Browser")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.IsBucketPath && data.BucketName != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<a href=\"/object-store/buckets\" class=\"btn btn-sm btn-outline-secondary\"><i class=\"fas fa-arrow-left me-1\"></i>Back to Buckets</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<button type=\"button\" class=\"btn btn-sm btn-outline-primary\" onclick=\"createFolder()\"><i class=\"fas fa-folder-plus me-1\"></i>New Folder</button> <button type=\"button\" class=\"btn btn-sm btn-outline-secondary\" onclick=\"uploadFile()\"><i class=\"fas fa-upload me-1\"></i>Upload</button> <button type=\"button\" class=\"btn btn-sm btn-outline-danger\" id=\"deleteSelectedBtn\" onclick=\"confirmDeleteSelected()\" style=\"display: none;\"><i class=\"fas fa-trash me-1\"></i>Delete Selected</button> <button type=\"button\" class=\"btn btn-sm btn-outline-info\" onclick=\"exportFileList()\"><i class=\"fas fa-download me-1\"></i>Export</button></div></div></div><!-- Breadcrumb Navigation --><nav aria-label=\"breadcrumb\" class=\"mb-3\"><ol class=\"breadcrumb\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for i, crumb := range data.Breadcrumbs {
if i == len(data.Breadcrumbs)-1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<li class=\"breadcrumb-item active\" aria-current=\"page\"><i class=\"fas fa-folder me-1\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 48, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<li class=\"breadcrumb-item\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/files?path=%s", crumb.Path))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"text-decoration-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if crumb.Name == "Root" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<i class=\"fas fa-home me-1\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<i class=\"fas fa-folder me-1\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 58, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</ol></nav><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Entries</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalEntries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 77, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div><div class=\"col-auto\"><i class=\"fas fa-list fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-success shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-success text-uppercase mb-1\">Directories</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", countDirectories(data.Entries)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 97, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div><div class=\"col-auto\"><i class=\"fas fa-folder fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Files</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", countFiles(data.Entries)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 117, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div><div class=\"col-auto\"><i class=\"fas fa-file fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-warning shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-warning text-uppercase mb-1\">Total Size</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(data.TotalSize))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 137, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div><div class=\"col-auto\"><i class=\"fas fa-hdd fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- File Listing --><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex justify-content-between align-items-center\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-folder-open me-2\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.CurrentPath == "/" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Root Directory")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if data.CurrentPath == "/buckets" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Object Store Buckets Directory <a href=\"/object-store/buckets\" class=\"btn btn-sm btn-outline-primary ms-2\"><i class=\"fas fa-cube me-1\"></i>Manage Buckets</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(filepath.Base(data.CurrentPath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 162, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</h6>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.ParentPath != data.CurrentPath {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/files?path=%s", data.ParentPath))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var11)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"btn btn-sm btn-outline-secondary\"><i class=\"fas fa-arrow-up me-1\"></i>Up</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Entries) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"table-responsive\"><table class=\"table table-hover\" id=\"fileTable\"><thead><tr><th width=\"40px\"><input type=\"checkbox\" id=\"selectAll\" onchange=\"toggleSelectAll()\"></th><th>Name</th><th>Size</th><th>Type</th><th>Modified</th><th>Permissions</th><th>Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, entry := range data.Entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<tr><td><input type=\"checkbox\" class=\"file-checkbox\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 192, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></td><td><div class=\"d-flex align-items-center\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if entry.IsDirectory {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<i class=\"fas fa-folder text-warning me-2\"></i> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/files?path=%s", entry.FullPath))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var13)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"text-decoration-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 199, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var15 = []any{fmt.Sprintf("fas %s text-muted me-2", getFileIcon(entry.Mime))}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"></i> <span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 203, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if entry.IsDirectory {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"text-muted\">—</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(entry.Size))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 211, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</td><td><span class=\"badge bg-light text-dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if entry.IsDirectory {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Directory")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(getMimeDisplayName(entry.Mime))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 219, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !entry.ModTime.IsZero() {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(entry.ModTime.Format("2006-01-02 15:04"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 225, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"text-muted\">—</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</td><td><code class=\"small\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</code></td><td><div class=\"btn-group btn-group-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !entry.IsDirectory {
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("downloadFile('%s')", entry.FullPath)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"Download\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("downloadFile('%s')", entry.FullPath)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\"><i class=\"fas fa-download\"></i></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("viewFile('%s')", entry.FullPath)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<button type=\"button\" class=\"btn btn-outline-info btn-sm\" title=\"View\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("viewFile('%s')", entry.FullPath)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"><i class=\"fas fa-eye\"></i></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("showProperties('%s')", entry.FullPath)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Properties\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("showProperties('%s')", entry.FullPath)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"><i class=\"fas fa-info\"></i></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("confirmDelete('%s')", entry.FullPath)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("confirmDelete('%s')", entry.FullPath)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"text-center py-5\"><i class=\"fas fa-folder-open fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">Empty Directory</h5><p class=\"text-muted\">This directory contains no files or subdirectories.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 271, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</small></div></div><!-- Create Folder Modal --><div class=\"modal fade\" id=\"createFolderModal\" tabindex=\"-1\" aria-labelledby=\"createFolderModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createFolderModalLabel\"><i class=\"fas fa-folder-plus me-2\"></i>Create New Folder</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createFolderForm\"><div class=\"mb-3\"><label for=\"folderName\" class=\"form-label\">Folder Name</label> <input type=\"text\" class=\"form-control\" id=\"folderName\" name=\"folderName\" required placeholder=\"Enter folder name\" maxlength=\"255\"><div class=\"form-text\">Folder names cannot contain / or \\ characters.</div></div><input type=\"hidden\" id=\"currentPath\" name=\"currentPath\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 296, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitCreateFolder()\"><i class=\"fas fa-folder-plus me-1\"></i>Create Folder</button></div></div></div></div><!-- Upload File Modal --><div class=\"modal fade\" id=\"uploadFileModal\" tabindex=\"-1\" aria-labelledby=\"uploadFileModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"uploadFileModalLabel\"><i class=\"fas fa-upload me-2\"></i>Upload Files</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"uploadFileForm\" enctype=\"multipart/form-data\"><div class=\"mb-3\"><label for=\"fileInput\" class=\"form-label\">Select Files</label> <input type=\"file\" class=\"form-control\" id=\"fileInput\" name=\"files\" multiple required><div class=\"form-text\">Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.</div></div><input type=\"hidden\" id=\"uploadPath\" name=\"path\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 328, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"><!-- File List Preview --><div id=\"fileListPreview\" class=\"mb-3\" style=\"display: none;\"><label class=\"form-label\">Selected Files:</label><div id=\"selectedFilesList\" class=\"border rounded p-2 bg-light\"><!-- Files will be listed here --></div></div><!-- Upload Progress --><div class=\"mb-3\" id=\"uploadProgress\" style=\"display: none;\"><label class=\"form-label\">Upload Progress:</label><div class=\"progress mb-2\"><div class=\"progress-bar progress-bar-striped progress-bar-animated\" role=\"progressbar\" style=\"width: 0%\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\">0%</div></div><div id=\"uploadStatus\" class=\"small text-muted\">Preparing upload...</div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitUploadFile()\"><i class=\"fas fa-upload me-1\"></i>Upload Files</button></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func countDirectories(entries []dash.FileEntry) int {
count := 0
for _, entry := range entries {
if entry.IsDirectory {
count++
}
}
return count
}
func countFiles(entries []dash.FileEntry) int {
count := 0
for _, entry := range entries {
if !entry.IsDirectory {
count++
}
}
return count
}
func getFileIcon(mime string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return "fa-image"
case strings.HasPrefix(mime, "video/"):
return "fa-video"
case strings.HasPrefix(mime, "audio/"):
return "fa-music"
case strings.HasPrefix(mime, "text/"):
return "fa-file-text"
case mime == "application/pdf":
return "fa-file-pdf"
case mime == "application/zip" || strings.Contains(mime, "archive"):
return "fa-file-archive"
case mime == "application/json":
return "fa-file-code"
case strings.Contains(mime, "script") || strings.Contains(mime, "javascript"):
return "fa-file-code"
default:
return "fa-file"
}
}
func getMimeDisplayName(mime string) string {
switch mime {
case "text/plain":
return "Text"
case "text/html":
return "HTML"
case "application/json":
return "JSON"
case "application/pdf":
return "PDF"
case "image/jpeg":
return "JPEG"
case "image/png":
return "PNG"
case "image/gif":
return "GIF"
case "video/mp4":
return "MP4"
case "audio/mpeg":
return "MP3"
case "application/zip":
return "ZIP"
default:
if strings.HasPrefix(mime, "image/") {
return "Image"
} else if strings.HasPrefix(mime, "video/") {
return "Video"
} else if strings.HasPrefix(mime, "audio/") {
return "Audio"
} else if strings.HasPrefix(mime, "text/") {
return "Text"
}
return "File"
}
}
var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,244 @@
package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
)
templ MaintenanceConfig(data *maintenance.MaintenanceConfigData) {
<div class="container-fluid">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h2 class="mb-0">
<i class="fas fa-cog me-2"></i>
Maintenance Configuration
</h2>
<div class="btn-group">
<a href="/maintenance" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>
Back to Queue
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">System Settings</h5>
</div>
<div class="card-body">
<form>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enabled" checked?={data.IsEnabled}>
<label class="form-check-label" for="enabled">
<strong>Enable Maintenance System</strong>
</label>
</div>
<small class="form-text text-muted">
When enabled, the system will automatically scan for and execute maintenance tasks.
</small>
</div>
<div class="mb-3">
<label for="scanInterval" class="form-label">Scan Interval (minutes)</label>
<input type="number" class="form-control" id="scanInterval"
value={fmt.Sprintf("%.0f", float64(data.Config.ScanIntervalSeconds)/60)} min="1" max="1440">
<small class="form-text text-muted">
How often to scan for maintenance tasks (1-1440 minutes).
</small>
</div>
<div class="mb-3">
<label for="workerTimeout" class="form-label">Worker Timeout (minutes)</label>
<input type="number" class="form-control" id="workerTimeout"
value={fmt.Sprintf("%.0f", float64(data.Config.WorkerTimeoutSeconds)/60)} min="1" max="60">
<small class="form-text text-muted">
How long to wait for worker heartbeat before considering it inactive (1-60 minutes).
</small>
</div>
<div class="mb-3">
<label for="taskTimeout" class="form-label">Task Timeout (hours)</label>
<input type="number" class="form-control" id="taskTimeout"
value={fmt.Sprintf("%.0f", float64(data.Config.TaskTimeoutSeconds)/3600)} min="1" max="24">
<small class="form-text text-muted">
Maximum time allowed for a single task to complete (1-24 hours).
</small>
</div>
<div class="mb-3">
<label for="globalMaxConcurrent" class="form-label">Global Concurrent Limit</label>
<input type="number" class="form-control" id="globalMaxConcurrent"
value={fmt.Sprintf("%d", data.Config.Policy.GlobalMaxConcurrent)} min="1" max="20">
<small class="form-text text-muted">
Maximum number of maintenance tasks that can run simultaneously across all workers (1-20).
</small>
</div>
<div class="mb-3">
<label for="maxRetries" class="form-label">Default Max Retries</label>
<input type="number" class="form-control" id="maxRetries"
value={fmt.Sprintf("%d", data.Config.MaxRetries)} min="0" max="10">
<small class="form-text text-muted">
Default number of times to retry failed tasks (0-10).
</small>
</div>
<div class="mb-3">
<label for="retryDelay" class="form-label">Retry Delay (minutes)</label>
<input type="number" class="form-control" id="retryDelay"
value={fmt.Sprintf("%.0f", float64(data.Config.RetryDelaySeconds)/60)} min="1" max="120">
<small class="form-text text-muted">
Time to wait before retrying failed tasks (1-120 minutes).
</small>
</div>
<div class="mb-3">
<label for="taskRetention" class="form-label">Task Retention (days)</label>
<input type="number" class="form-control" id="taskRetention"
value={fmt.Sprintf("%.0f", float64(data.Config.TaskRetentionSeconds)/(24*3600))} min="1" max="30">
<small class="form-text text-muted">
How long to keep completed/failed task records (1-30 days).
</small>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" onclick="saveConfiguration()">
<i class="fas fa-save me-1"></i>
Save Configuration
</button>
<button type="button" class="btn btn-secondary" onclick="resetToDefaults()">
<i class="fas fa-undo me-1"></i>
Reset to Defaults
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Individual Task Configuration Menu -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-cogs me-2"></i>
Task Configuration
</h5>
</div>
<div class="card-body">
<p class="text-muted mb-3">Configure specific settings for each maintenance task type.</p>
<div class="list-group">
for _, menuItem := range data.MenuItems {
<a href={templ.SafeURL(menuItem.Path)} class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">
<i class={menuItem.Icon + " me-2"}></i>
{menuItem.DisplayName}
</h6>
if data.Config.Policy.IsTaskEnabled(menuItem.TaskType) {
<span class="badge bg-success">Enabled</span>
} else {
<span class="badge bg-secondary">Disabled</span>
}
</div>
<p class="mb-1 small text-muted">{menuItem.Description}</p>
</a>
}
</div>
</div>
</div>
</div>
</div>
<!-- Statistics Overview -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">System Statistics</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Last Scan</h6>
<p class="mb-0">{data.LastScanTime.Format("2006-01-02 15:04:05")}</p>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Next Scan</h6>
<p class="mb-0">{data.NextScanTime.Format("2006-01-02 15:04:05")}</p>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Total Tasks</h6>
<p class="mb-0">{fmt.Sprintf("%d", data.SystemStats.TotalTasks)}</p>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h6 class="text-muted">Active Workers</h6>
<p class="mb-0">{fmt.Sprintf("%d", data.SystemStats.ActiveWorkers)}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function saveConfiguration() {
const config = {
enabled: document.getElementById('enabled').checked,
scan_interval_seconds: parseInt(document.getElementById('scanInterval').value) * 60, // Convert to seconds
policy: {
vacuum_enabled: document.getElementById('vacuumEnabled').checked,
vacuum_garbage_ratio: parseFloat(document.getElementById('vacuumGarbageRatio').value) / 100,
replication_fix_enabled: document.getElementById('replicationFixEnabled').checked,
}
};
fetch('/api/maintenance/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Configuration saved successfully');
} else {
alert('Failed to save configuration: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
function resetToDefaults() {
if (confirm('Are you sure you want to reset to default configuration? This will overwrite your current settings.')) {
// Reset form to defaults
document.getElementById('enabled').checked = false;
document.getElementById('scanInterval').value = '30';
document.getElementById('vacuumEnabled').checked = false;
document.getElementById('vacuumGarbageRatio').value = '30';
document.getElementById('replicationFixEnabled').checked = false;
}
}
</script>
}
@@ -0,0 +1,280 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
)
func MaintenanceConfig(data *maintenance.MaintenanceConfigData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container-fluid\"><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center\"><h2 class=\"mb-0\"><i class=\"fas fa-cog me-2\"></i> Maintenance Configuration</h2><div class=\"btn-group\"><a href=\"/maintenance\" class=\"btn btn-outline-secondary\"><i class=\"fas fa-arrow-left me-1\"></i> Back to Queue</a></div></div></div></div><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">System Settings</h5></div><div class=\"card-body\"><form><div class=\"mb-3\"><div class=\"form-check form-switch\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enabled\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.IsEnabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "> <label class=\"form-check-label\" for=\"enabled\"><strong>Enable Maintenance System</strong></label></div><small class=\"form-text text-muted\">When enabled, the system will automatically scan for and execute maintenance tasks.</small></div><div class=\"mb-3\"><label for=\"scanInterval\" class=\"form-label\">Scan Interval (minutes)</label> <input type=\"number\" class=\"form-control\" id=\"scanInterval\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.ScanIntervalSeconds)/60))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 50, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" min=\"1\" max=\"1440\"> <small class=\"form-text text-muted\">How often to scan for maintenance tasks (1-1440 minutes).</small></div><div class=\"mb-3\"><label for=\"workerTimeout\" class=\"form-label\">Worker Timeout (minutes)</label> <input type=\"number\" class=\"form-control\" id=\"workerTimeout\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.WorkerTimeoutSeconds)/60))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 59, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" min=\"1\" max=\"60\"> <small class=\"form-text text-muted\">How long to wait for worker heartbeat before considering it inactive (1-60 minutes).</small></div><div class=\"mb-3\"><label for=\"taskTimeout\" class=\"form-label\">Task Timeout (hours)</label> <input type=\"number\" class=\"form-control\" id=\"taskTimeout\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.TaskTimeoutSeconds)/3600))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 68, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" min=\"1\" max=\"24\"> <small class=\"form-text text-muted\">Maximum time allowed for a single task to complete (1-24 hours).</small></div><div class=\"mb-3\"><label for=\"globalMaxConcurrent\" class=\"form-label\">Global Concurrent Limit</label> <input type=\"number\" class=\"form-control\" id=\"globalMaxConcurrent\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Config.Policy.GlobalMaxConcurrent))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 77, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" min=\"1\" max=\"20\"> <small class=\"form-text text-muted\">Maximum number of maintenance tasks that can run simultaneously across all workers (1-20).</small></div><div class=\"mb-3\"><label for=\"maxRetries\" class=\"form-label\">Default Max Retries</label> <input type=\"number\" class=\"form-control\" id=\"maxRetries\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Config.MaxRetries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 86, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" min=\"0\" max=\"10\"> <small class=\"form-text text-muted\">Default number of times to retry failed tasks (0-10).</small></div><div class=\"mb-3\"><label for=\"retryDelay\" class=\"form-label\">Retry Delay (minutes)</label> <input type=\"number\" class=\"form-control\" id=\"retryDelay\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.RetryDelaySeconds)/60))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 95, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" min=\"1\" max=\"120\"> <small class=\"form-text text-muted\">Time to wait before retrying failed tasks (1-120 minutes).</small></div><div class=\"mb-3\"><label for=\"taskRetention\" class=\"form-label\">Task Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"taskRetention\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.TaskRetentionSeconds)/(24*3600)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 104, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" min=\"1\" max=\"30\"> <small class=\"form-text text-muted\">How long to keep completed/failed task records (1-30 days).</small></div><div class=\"d-flex gap-2\"><button type=\"button\" class=\"btn btn-primary\" onclick=\"saveConfiguration()\"><i class=\"fas fa-save me-1\"></i> Save Configuration</button> <button type=\"button\" class=\"btn btn-secondary\" onclick=\"resetToDefaults()\"><i class=\"fas fa-undo me-1\"></i> Reset to Defaults</button></div></form></div></div></div></div><!-- Individual Task Configuration Menu --><div class=\"row mt-4\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"fas fa-cogs me-2\"></i> Task Configuration</h5></div><div class=\"card-body\"><p class=\"text-muted mb-3\">Configure specific settings for each maintenance task type.</p><div class=\"list-group\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, menuItem := range data.MenuItems {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.SafeURL = templ.SafeURL(menuItem.Path)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var9)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"list-group-item list-group-item-action\"><div class=\"d-flex w-100 justify-content-between\"><h6 class=\"mb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 = []any{menuItem.Icon + " me-2"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var10).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 144, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h6>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Config.Policy.IsTaskEnabled(menuItem.TaskType) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"badge bg-success\">Enabled</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"badge bg-secondary\">Disabled</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><p class=\"mb-1 small text-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 152, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div></div></div></div><!-- Statistics Overview --><div class=\"row mt-4\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">System Statistics</h5></div><div class=\"card-body\"><div class=\"row\"><div class=\"col-md-3\"><div class=\"text-center\"><h6 class=\"text-muted\">Last Scan</h6><p class=\"mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastScanTime.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 173, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p></div></div><div class=\"col-md-3\"><div class=\"text-center\"><h6 class=\"text-muted\">Next Scan</h6><p class=\"mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.NextScanTime.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 179, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p></div></div><div class=\"col-md-3\"><div class=\"text-center\"><h6 class=\"text-muted\">Total Tasks</h6><p class=\"mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.SystemStats.TotalTasks))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 185, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p></div></div><div class=\"col-md-3\"><div class=\"text-center\"><h6 class=\"text-muted\">Active Workers</h6><p class=\"mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.SystemStats.ActiveWorkers))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 191, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p></div></div></div></div></div></div></div></div><script>\n function saveConfiguration() {\n const config = {\n enabled: document.getElementById('enabled').checked,\n scan_interval_seconds: parseInt(document.getElementById('scanInterval').value) * 60, // Convert to seconds\n policy: {\n vacuum_enabled: document.getElementById('vacuumEnabled').checked,\n vacuum_garbage_ratio: parseFloat(document.getElementById('vacuumGarbageRatio').value) / 100,\n replication_fix_enabled: document.getElementById('replicationFixEnabled').checked,\n }\n };\n\n fetch('/api/maintenance/config', {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(config)\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Configuration saved successfully');\n } else {\n alert('Failed to save configuration: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n alert('Error: ' + error.message);\n });\n }\n\n function resetToDefaults() {\n if (confirm('Are you sure you want to reset to default configuration? This will overwrite your current settings.')) {\n // Reset form to defaults\n document.getElementById('enabled').checked = false;\n document.getElementById('scanInterval').value = '30';\n document.getElementById('vacuumEnabled').checked = false;\n document.getElementById('vacuumGarbageRatio').value = '30';\n document.getElementById('replicationFixEnabled').checked = false;\n }\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+289
View File
@@ -0,0 +1,289 @@
package app
import (
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
)
templ MaintenanceQueue(data *maintenance.MaintenanceQueueData) {
<div class="container-fluid">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h2 class="mb-0">
<i class="fas fa-tasks me-2"></i>
Maintenance Queue
</h2>
<div class="btn-group">
<button type="button" class="btn btn-primary" onclick="triggerScan()">
<i class="fas fa-search me-1"></i>
Trigger Scan
</button>
<button type="button" class="btn btn-secondary" onclick="refreshPage()">
<i class="fas fa-sync-alt me-1"></i>
Refresh
</button>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body text-center">
<i class="fas fa-clock fa-2x text-primary mb-2"></i>
<h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.PendingTasks)}</h4>
<p class="text-muted mb-0">Pending Tasks</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body text-center">
<i class="fas fa-running fa-2x text-warning mb-2"></i>
<h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.RunningTasks)}</h4>
<p class="text-muted mb-0">Running Tasks</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body text-center">
<i class="fas fa-check-circle fa-2x text-success mb-2"></i>
<h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.CompletedToday)}</h4>
<p class="text-muted mb-0">Completed Today</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-danger">
<div class="card-body text-center">
<i class="fas fa-exclamation-triangle fa-2x text-danger mb-2"></i>
<h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.FailedToday)}</h4>
<p class="text-muted mb-0">Failed Today</p>
</div>
</div>
</div>
</div>
<!-- Simple task queue display -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Task Queue</h5>
</div>
<div class="card-body">
if len(data.Tasks) == 0 {
<div class="text-center text-muted py-4">
<i class="fas fa-clipboard-list fa-3x mb-3"></i>
<p>No maintenance tasks in queue</p>
<small>Tasks will appear here when the system detects maintenance needs</small>
</div>
} else {
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>Status</th>
<th>Volume</th>
<th>Server</th>
<th>Created</th>
</tr>
</thead>
<tbody>
for _, task := range data.Tasks {
<tr>
<td><code>{task.ID[:8]}...</code></td>
<td>{string(task.Type)}</td>
<td>{string(task.Status)}</td>
<td>{fmt.Sprintf("%d", task.VolumeID)}</td>
<td>{task.Server}</td>
<td>{task.CreatedAt.Format("2006-01-02 15:04")}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
</div>
<!-- Workers Summary -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Active Workers</h5>
</div>
<div class="card-body">
if len(data.Workers) == 0 {
<div class="text-center text-muted py-4">
<i class="fas fa-robot fa-3x mb-3"></i>
<p>No workers are currently active</p>
<small>Start workers using: <code>weed worker -admin=localhost:9333</code></small>
</div>
} else {
<div class="row">
for _, worker := range data.Workers {
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-body">
<h6 class="card-title">{worker.ID}</h6>
<p class="card-text">
<small class="text-muted">{worker.Address}</small><br/>
Status: {worker.Status}<br/>
Load: {fmt.Sprintf("%d/%d", worker.CurrentLoad, worker.MaxConcurrent)}
</p>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
</div>
</div>
</div>
<script>
// Auto-refresh every 10 seconds
setInterval(function() {
if (!document.hidden) {
window.location.reload();
}
}, 10000);
function triggerScan() {
fetch('/api/maintenance/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Maintenance scan triggered successfully');
setTimeout(() => window.location.reload(), 2000);
} else {
alert('Failed to trigger scan: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
</script>
}
// Helper components
templ TaskTypeIcon(taskType maintenance.MaintenanceTaskType) {
<i class={maintenance.GetTaskIcon(taskType) + " me-1"}></i>
}
templ PriorityBadge(priority maintenance.MaintenanceTaskPriority) {
switch priority {
case maintenance.PriorityCritical:
<span class="badge bg-danger">Critical</span>
case maintenance.PriorityHigh:
<span class="badge bg-warning">High</span>
case maintenance.PriorityNormal:
<span class="badge bg-primary">Normal</span>
case maintenance.PriorityLow:
<span class="badge bg-secondary">Low</span>
default:
<span class="badge bg-light text-dark">Unknown</span>
}
}
templ StatusBadge(status maintenance.MaintenanceTaskStatus) {
switch status {
case maintenance.TaskStatusPending:
<span class="badge bg-secondary">Pending</span>
case maintenance.TaskStatusAssigned:
<span class="badge bg-info">Assigned</span>
case maintenance.TaskStatusInProgress:
<span class="badge bg-warning">Running</span>
case maintenance.TaskStatusCompleted:
<span class="badge bg-success">Completed</span>
case maintenance.TaskStatusFailed:
<span class="badge bg-danger">Failed</span>
case maintenance.TaskStatusCancelled:
<span class="badge bg-light text-dark">Cancelled</span>
default:
<span class="badge bg-light text-dark">Unknown</span>
}
}
templ ProgressBar(progress float64, status maintenance.MaintenanceTaskStatus) {
if status == maintenance.TaskStatusInProgress || status == maintenance.TaskStatusAssigned {
<div class="progress" style="height: 8px; min-width: 100px;">
<div class="progress-bar" role="progressbar" style={fmt.Sprintf("width: %.1f%%", progress)}>
</div>
</div>
<small class="text-muted">{fmt.Sprintf("%.1f%%", progress)}</small>
} else if status == maintenance.TaskStatusCompleted {
<div class="progress" style="height: 8px; min-width: 100px;">
<div class="progress-bar bg-success" role="progressbar" style="width: 100%">
</div>
</div>
<small class="text-success">100%</small>
} else {
<span class="text-muted">-</span>
}
}
templ WorkerStatusBadge(status string) {
switch status {
case "active":
<span class="badge bg-success">Active</span>
case "busy":
<span class="badge bg-warning">Busy</span>
case "inactive":
<span class="badge bg-secondary">Inactive</span>
default:
<span class="badge bg-light text-dark">Unknown</span>
}
}
// Helper functions (would be defined in Go)
func getWorkerStatusColor(status string) string {
switch status {
case "active":
return "success"
case "busy":
return "warning"
case "inactive":
return "secondary"
default:
return "light"
}
}
func formatTimeAgo(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return "just now"
} else if duration < time.Hour {
minutes := int(duration.Minutes())
return fmt.Sprintf("%dm ago", minutes)
} else if duration < 24*time.Hour {
hours := int(duration.Hours())
return fmt.Sprintf("%dh ago", hours)
} else {
days := int(duration.Hours() / 24)
return fmt.Sprintf("%dd ago", days)
}
}
@@ -0,0 +1,585 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
"time"
)
func MaintenanceQueue(data *maintenance.MaintenanceQueueData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container-fluid\"><!-- Header --><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center\"><h2 class=\"mb-0\"><i class=\"fas fa-tasks me-2\"></i> Maintenance Queue</h2><div class=\"btn-group\"><button type=\"button\" class=\"btn btn-primary\" onclick=\"triggerScan()\"><i class=\"fas fa-search me-1\"></i> Trigger Scan</button> <button type=\"button\" class=\"btn btn-secondary\" onclick=\"refreshPage()\"><i class=\"fas fa-sync-alt me-1\"></i> Refresh</button></div></div></div></div><!-- Statistics Cards --><div class=\"row mb-4\"><div class=\"col-md-3\"><div class=\"card border-primary\"><div class=\"card-body text-center\"><i class=\"fas fa-clock fa-2x text-primary mb-2\"></i><h4 class=\"mb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Stats.PendingTasks))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 39, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h4><p class=\"text-muted mb-0\">Pending Tasks</p></div></div></div><div class=\"col-md-3\"><div class=\"card border-warning\"><div class=\"card-body text-center\"><i class=\"fas fa-running fa-2x text-warning mb-2\"></i><h4 class=\"mb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Stats.RunningTasks))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 48, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h4><p class=\"text-muted mb-0\">Running Tasks</p></div></div></div><div class=\"col-md-3\"><div class=\"card border-success\"><div class=\"card-body text-center\"><i class=\"fas fa-check-circle fa-2x text-success mb-2\"></i><h4 class=\"mb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Stats.CompletedToday))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 57, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h4><p class=\"text-muted mb-0\">Completed Today</p></div></div></div><div class=\"col-md-3\"><div class=\"card border-danger\"><div class=\"card-body text-center\"><i class=\"fas fa-exclamation-triangle fa-2x text-danger mb-2\"></i><h4 class=\"mb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Stats.FailedToday))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 66, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</h4><p class=\"text-muted mb-0\">Failed Today</p></div></div></div></div><!-- Simple task queue display --><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">Task Queue</h5></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Tasks) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"text-center text-muted py-4\"><i class=\"fas fa-clipboard-list fa-3x mb-3\"></i><p>No maintenance tasks in queue</p><small>Tasks will appear here when the system detects maintenance needs</small></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"table-responsive\"><table class=\"table table-hover\"><thead><tr><th>ID</th><th>Type</th><th>Status</th><th>Volume</th><th>Server</th><th>Created</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, task := range data.Tasks {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<tr><td><code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(task.ID[:8])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 103, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "...</code></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(string(task.Type))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 104, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(task.Status))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 105, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", task.VolumeID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 106, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(task.Server)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 107, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(task.CreatedAt.Format("2006-01-02 15:04"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 108, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div></div></div><!-- Workers Summary --><div class=\"row mt-4\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">Active Workers</h5></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Workers) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"text-center text-muted py-4\"><i class=\"fas fa-robot fa-3x mb-3\"></i><p>No workers are currently active</p><small>Start workers using: <code>weed worker -admin=localhost:9333</code></small></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"row\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, worker := range data.Workers {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"col-md-4 mb-3\"><div class=\"card\"><div class=\"card-body\"><h6 class=\"card-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(worker.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 140, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h6><p class=\"card-text\"><small class=\"text-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 142, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</small><br>Status: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 143, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<br>Load: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", worker.CurrentLoad, worker.MaxConcurrent))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 144, Col: 121}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div></div></div></div></div><script>\n // Auto-refresh every 10 seconds\n setInterval(function() {\n if (!document.hidden) {\n window.location.reload();\n }\n }, 10000);\n\n function triggerScan() {\n fetch('/api/maintenance/scan', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n }\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Maintenance scan triggered successfully');\n setTimeout(() => window.location.reload(), 2000);\n } else {\n alert('Failed to trigger scan: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n alert('Error: ' + error.message);\n });\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Helper components
func TaskTypeIcon(taskType maintenance.MaintenanceTaskType) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
if templ_7745c5c3_Var16 == nil {
templ_7745c5c3_Var16 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var17 = []any{maintenance.GetTaskIcon(taskType) + " me-1"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var17).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func PriorityBadge(priority maintenance.MaintenanceTaskPriority) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var19 := templ.GetChildren(ctx)
if templ_7745c5c3_Var19 == nil {
templ_7745c5c3_Var19 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch priority {
case maintenance.PriorityCritical:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"badge bg-danger\">Critical</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case maintenance.PriorityHigh:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"badge bg-warning\">High</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case maintenance.PriorityNormal:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge bg-primary\">Normal</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case maintenance.PriorityLow:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"badge bg-secondary\">Low</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"badge bg-light text-dark\">Unknown</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func StatusBadge(status maintenance.MaintenanceTaskStatus) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
if templ_7745c5c3_Var20 == nil {
templ_7745c5c3_Var20 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch status {
case maintenance.TaskStatusPending:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"badge bg-secondary\">Pending</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case maintenance.TaskStatusAssigned:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<span class=\"badge bg-info\">Assigned</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case maintenance.TaskStatusInProgress:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"badge bg-warning\">Running</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case maintenance.TaskStatusCompleted:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<span class=\"badge bg-success\">Completed</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case maintenance.TaskStatusFailed:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"badge bg-danger\">Failed</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case maintenance.TaskStatusCancelled:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<span class=\"badge bg-light text-dark\">Cancelled</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"badge bg-light text-dark\">Unknown</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func ProgressBar(progress float64, status maintenance.MaintenanceTaskStatus) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var21 := templ.GetChildren(ctx)
if templ_7745c5c3_Var21 == nil {
templ_7745c5c3_Var21 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if status == maintenance.TaskStatusInProgress || status == maintenance.TaskStatusAssigned {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div class=\"progress\" style=\"height: 8px; min-width: 100px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %.1f%%", progress))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 231, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"></div></div><small class=\"text-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f%%", progress))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 234, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</small>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if status == maintenance.TaskStatusCompleted {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"progress\" style=\"height: 8px; min-width: 100px;\"><div class=\"progress-bar bg-success\" role=\"progressbar\" style=\"width: 100%\"></div></div><small class=\"text-success\">100%</small>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<span class=\"text-muted\">-</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func WorkerStatusBadge(status string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var24 := templ.GetChildren(ctx)
if templ_7745c5c3_Var24 == nil {
templ_7745c5c3_Var24 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch status {
case "active":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span class=\"badge bg-success\">Active</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "busy":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"badge bg-warning\">Busy</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "inactive":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"badge bg-secondary\">Inactive</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span class=\"badge bg-light text-dark\">Unknown</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
// Helper functions (would be defined in Go)
func getWorkerStatusColor(status string) string {
switch status {
case "active":
return "success"
case "busy":
return "warning"
case "inactive":
return "secondary"
default:
return "light"
}
}
func formatTimeAgo(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return "just now"
} else if duration < time.Hour {
minutes := int(duration.Minutes())
return fmt.Sprintf("%dm ago", minutes)
} else if duration < 24*time.Hour {
hours := int(duration.Hours())
return fmt.Sprintf("%dh ago", hours)
} else {
days := int(duration.Hours() / 24)
return fmt.Sprintf("%dd ago", days)
}
}
var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,340 @@
package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"time"
)
templ MaintenanceWorkers(data *dash.MaintenanceWorkersData) {
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0 text-gray-800">Maintenance Workers</h1>
<p class="text-muted">Monitor and manage maintenance workers</p>
</div>
<div class="text-end">
<small class="text-muted">Last updated: { data.LastUpdated.Format("2006-01-02 15:04:05") }</small>
</div>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Workers
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{ fmt.Sprintf("%d", len(data.Workers)) }</div>
</div>
<div class="col-auto">
<i class="fas fa-users fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
Active Workers
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", data.ActiveWorkers) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-check-circle fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
Busy Workers
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", data.BusyWorkers) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-spinner fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Total Load
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{ fmt.Sprintf("%d", data.TotalLoad) }
</div>
</div>
<div class="col-auto">
<i class="fas fa-tasks fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Workers Table -->
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Worker Details</h6>
</div>
<div class="card-body">
if len(data.Workers) == 0 {
<div class="text-center py-4">
<i class="fas fa-users fa-3x text-gray-300 mb-3"></i>
<h5 class="text-gray-600">No Workers Found</h5>
<p class="text-muted">No maintenance workers are currently registered.</p>
<div class="alert alert-info mt-3">
<strong>💡 Tip:</strong> To start a worker, run:
<br><code>weed worker -admin=&lt;admin_server&gt; -capabilities=vacuum,ec,replication</code>
</div>
</div>
} else {
<div class="table-responsive">
<table class="table table-bordered table-hover" id="workersTable">
<thead class="table-light">
<tr>
<th>Worker ID</th>
<th>Address</th>
<th>Status</th>
<th>Capabilities</th>
<th>Load</th>
<th>Current Tasks</th>
<th>Performance</th>
<th>Last Heartbeat</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, worker := range data.Workers {
<tr>
<td>
<code>{ worker.Worker.ID }</code>
</td>
<td>
<code>{ worker.Worker.Address }</code>
</td>
<td>
if worker.Worker.Status == "active" {
<span class="badge bg-success">Active</span>
} else if worker.Worker.Status == "busy" {
<span class="badge bg-warning">Busy</span>
} else {
<span class="badge bg-danger">Inactive</span>
}
</td>
<td>
<div class="d-flex flex-wrap gap-1">
for _, capability := range worker.Worker.Capabilities {
<span class="badge bg-secondary rounded-pill">{ string(capability) }</span>
}
</div>
</td>
<td>
<div class="progress" style="height: 20px;">
if worker.Worker.MaxConcurrent > 0 {
<div class="progress-bar" role="progressbar"
style={ fmt.Sprintf("width: %d%%", (worker.Worker.CurrentLoad*100)/worker.Worker.MaxConcurrent) }
aria-valuenow={ fmt.Sprintf("%d", worker.Worker.CurrentLoad) }
aria-valuemin="0"
aria-valuemax={ fmt.Sprintf("%d", worker.Worker.MaxConcurrent) }>
{ fmt.Sprintf("%d/%d", worker.Worker.CurrentLoad, worker.Worker.MaxConcurrent) }
</div>
} else {
<div class="progress-bar" role="progressbar" style="width: 0%">0/0</div>
}
</div>
</td>
<td>
{ fmt.Sprintf("%d", len(worker.CurrentTasks)) }
</td>
<td>
<small>
<div>✅ { fmt.Sprintf("%d", worker.Performance.TasksCompleted) }</div>
<div>❌ { fmt.Sprintf("%d", worker.Performance.TasksFailed) }</div>
<div>📊 { fmt.Sprintf("%.1f%%", worker.Performance.SuccessRate) }</div>
</small>
</td>
<td>
if time.Since(worker.Worker.LastHeartbeat) < 2*time.Minute {
<span class="text-success">
<i class="fas fa-heartbeat"></i>
{ worker.Worker.LastHeartbeat.Format("15:04:05") }
</span>
} else {
<span class="text-danger">
<i class="fas fa-exclamation-triangle"></i>
{ worker.Worker.LastHeartbeat.Format("15:04:05") }
</span>
}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-info" onclick={ templ.ComponentScript{Call: "showWorkerDetails"} } data-worker-id={ worker.Worker.ID }>
<i class="fas fa-info-circle"></i>
</button>
if worker.Worker.Status == "active" {
<button type="button" class="btn btn-outline-warning" onclick={ templ.ComponentScript{Call: "pauseWorker"} } data-worker-id={ worker.Worker.ID }>
<i class="fas fa-pause"></i>
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
</div>
</div>
<!-- Worker Details Modal -->
<div class="modal fade" id="workerDetailsModal" tabindex="-1" aria-labelledby="workerDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="workerDetailsModalLabel">Worker Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="workerDetailsContent">
<!-- Content will be loaded dynamically -->
</div>
</div>
</div>
</div>
<script>
function showWorkerDetails(event) {
const workerID = event.target.closest('button').getAttribute('data-worker-id');
// Show modal
var modal = new bootstrap.Modal(document.getElementById('workerDetailsModal'));
// Load worker details
fetch(`/api/maintenance/workers/${workerID}`)
.then(response => response.json())
.then(data => {
const content = document.getElementById('workerDetailsContent');
content.innerHTML = `
<div class="row">
<div class="col-md-6">
<h6>Worker Information</h6>
<ul class="list-unstyled">
<li><strong>ID:</strong> ${data.worker.id}</li>
<li><strong>Address:</strong> ${data.worker.address}</li>
<li><strong>Status:</strong> ${data.worker.status}</li>
<li><strong>Max Concurrent:</strong> ${data.worker.max_concurrent}</li>
<li><strong>Current Load:</strong> ${data.worker.current_load}</li>
</ul>
</div>
<div class="col-md-6">
<h6>Performance Metrics</h6>
<ul class="list-unstyled">
<li><strong>Tasks Completed:</strong> ${data.performance.tasks_completed}</li>
<li><strong>Tasks Failed:</strong> ${data.performance.tasks_failed}</li>
<li><strong>Success Rate:</strong> ${data.performance.success_rate.toFixed(1)}%</li>
<li><strong>Average Task Time:</strong> ${formatDuration(data.performance.average_task_time)}</li>
<li><strong>Uptime:</strong> ${formatDuration(data.performance.uptime)}</li>
</ul>
</div>
</div>
<hr>
<h6>Current Tasks</h6>
${data.current_tasks.length === 0 ?
'<p class="text-muted">No current tasks</p>' :
data.current_tasks.map(task => `
<div class="card mb-2">
<div class="card-body py-2">
<div class="d-flex justify-content-between">
<span><strong>${task.type}</strong> - Volume ${task.volume_id}</span>
<span class="badge bg-info">${task.status}</span>
</div>
<small class="text-muted">${task.reason}</small>
</div>
</div>
`).join('')
}
`;
modal.show();
})
.catch(error => {
console.error('Error loading worker details:', error);
const content = document.getElementById('workerDetailsContent');
content.innerHTML = '<div class="alert alert-danger">Failed to load worker details</div>';
modal.show();
});
}
function pauseWorker(event) {
const workerID = event.target.closest('button').getAttribute('data-worker-id');
if (confirm('Are you sure you want to pause this worker?')) {
fetch(`/api/maintenance/workers/${workerID}/pause`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Failed to pause worker: ' + data.error);
}
})
.catch(error => {
console.error('Error pausing worker:', error);
alert('Failed to pause worker');
});
}
}
function formatDuration(nanoseconds) {
const seconds = Math.floor(nanoseconds / 1000000000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
</script>
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,333 @@
package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
<div class="container-fluid">
<!-- Page Header -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="h3 mb-0 text-gray-800">
<i class="fas fa-users me-2"></i>Object Store Users
</h1>
<p class="mb-0 text-muted">Manage S3 API users and their access credentials</p>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#createUserModal">
<i class="fas fa-plus me-1"></i>Create User
</button>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Users
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalUsers)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-users fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
Total Users
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", len(data.Users))}
</div>
</div>
<div class="col-auto">
<i class="fas fa-user-check fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
Last Updated
</div>
<div class="h6 mb-0 font-weight-bold text-gray-800">
{data.LastUpdated.Format("15:04")}
</div>
</div>
<div class="col-auto">
<i class="fas fa-clock fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Users Table -->
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-users me-2"></i>Object Store Users
</h6>
<div class="dropdown no-arrow">
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i>
</a>
<div class="dropdown-menu dropdown-menu-right shadow animated--fade-in">
<div class="dropdown-header">Actions:</div>
<a class="dropdown-item" href="#" onclick="exportUsers()">
<i class="fas fa-download me-2"></i>Export List
</a>
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" width="100%" cellspacing="0" id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Access Key</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, user := range data.Users {
<tr>
<td>
<div class="d-flex align-items-center">
<i class="fas fa-user me-2 text-muted"></i>
<strong>{user.Username}</strong>
</div>
</td>
<td>{user.Email}</td>
<td>
<code class="text-muted">{user.AccessKey}</code>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button type="button"
class="btn btn-outline-info btn-sm"
title="View Details"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showUserDetails('%s')", user.Username)} }>
<i class="fas fa-eye"></i>
</button>
<button type="button"
class="btn btn-outline-primary btn-sm"
title="Edit User"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("editUser('%s')", user.Username)} }>
<i class="fas fa-edit"></i>
</button>
<button type="button"
class="btn btn-outline-warning btn-sm"
title="Manage Keys"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("manageAccessKeys('%s')", user.Username)} }>
<i class="fas fa-key"></i>
</button>
<button type="button"
class="btn btn-outline-danger btn-sm"
title="Delete User"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("deleteUser('%s')", user.Username)} }>
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
if len(data.Users) == 0 {
<tr>
<td colspan="4" class="text-center text-muted py-4">
<i class="fas fa-users fa-3x mb-3 text-muted"></i>
<div>
<h5>No users found</h5>
<p>Create your first object store user to get started.</p>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}
</small>
</div>
</div>
</div>
<!-- Create User Modal -->
<div class="modal fade" id="createUserModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-user-plus me-2"></i>Create New User
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createUserForm">
<div class="mb-3">
<label for="username" class="form-label">Username *</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email">
</div>
<div class="mb-3">
<label for="actions" class="form-label">Permissions</label>
<select multiple class="form-control" id="actions" name="actions">
<option value="Admin">Admin (Full Access)</option>
<option value="Read">Read</option>
<option value="Write">Write</option>
<option value="List">List</option>
<option value="Tagging">Tagging</option>
</select>
<small class="form-text text-muted">Hold Ctrl/Cmd to select multiple permissions</small>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="generateKey" name="generateKey" checked>
<label class="form-check-label" for="generateKey">
Generate access key automatically
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="handleCreateUser()">Create User</button>
</div>
</div>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-user-edit me-2"></i>Edit User
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editUserForm">
<input type="hidden" id="editUsername" name="username">
<div class="mb-3">
<label for="editEmail" class="form-label">Email</label>
<input type="email" class="form-control" id="editEmail" name="email">
</div>
<div class="mb-3">
<label for="editActions" class="form-label">Permissions</label>
<select multiple class="form-control" id="editActions" name="actions">
<option value="Admin">Admin (Full Access)</option>
<option value="Read">Read</option>
<option value="Write">Write</option>
<option value="List">List</option>
<option value="Tagging">Tagging</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="handleUpdateUser()">Update User</button>
</div>
</div>
</div>
</div>
<!-- User Details Modal -->
<div class="modal fade" id="userDetailsModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-user me-2"></i>User Details
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="userDetailsContent">
<!-- Content will be loaded dynamically -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Access Keys Management Modal -->
<div class="modal fade" id="accessKeysModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-key me-2"></i>Manage Access Keys
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6>Access Keys for <span id="accessKeysUsername"></span></h6>
<button type="button" class="btn btn-primary btn-sm" onclick="createAccessKey()">
<i class="fas fa-plus me-1"></i>Create New Key
</button>
</div>
<div id="accessKeysContent">
<!-- Content will be loaded dynamically -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- JavaScript for user management -->
<script>
// User management functions will be included in admin.js
</script>
}
// Helper functions for template
@@ -0,0 +1,221 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container-fluid\"><!-- Page Header --><div class=\"d-sm-flex align-items-center justify-content-between mb-4\"><div><h1 class=\"h3 mb-0 text-gray-800\"><i class=\"fas fa-users me-2\"></i>Object Store Users</h1><p class=\"mb-0 text-muted\">Manage S3 API users and their access credentials</p></div><div class=\"d-flex gap-2\"><button type=\"button\" class=\"btn btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createUserModal\"><i class=\"fas fa-plus me-1\"></i>Create User</button></div></div><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Users</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalUsers))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 38, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-users fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-success shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-success text-uppercase mb-1\">Total Users</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.Users)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 58, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-user-check fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Last Updated</div><div class=\"h6 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("15:04"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 78, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-clock fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Users Table --><div class=\"row\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex flex-row align-items-center justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-users me-2\"></i>Object Store Users</h6><div class=\"dropdown no-arrow\"><a class=\"dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-ellipsis-v fa-sm fa-fw text-gray-400\"></i></a><div class=\"dropdown-menu dropdown-menu-right shadow animated--fade-in\"><div class=\"dropdown-header\">Actions:</div><a class=\"dropdown-item\" href=\"#\" onclick=\"exportUsers()\"><i class=\"fas fa-download me-2\"></i>Export List</a></div></div></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-hover\" width=\"100%\" cellspacing=\"0\" id=\"usersTable\"><thead><tr><th>Username</th><th>Email</th><th>Access Key</th><th>Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, user := range data.Users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr><td><div class=\"d-flex align-items-center\"><i class=\"fas fa-user me-2 text-muted\"></i> <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 127, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</strong></div></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 130, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</td><td><code class=\"text-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.AccessKey)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 132, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</code></td><td><div class=\"btn-group btn-group-sm\" role=\"group\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("showUserDetails('%s')", user.Username)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<button type=\"button\" class=\"btn btn-outline-info btn-sm\" title=\"View Details\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("showUserDetails('%s')", user.Username)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><i class=\"fas fa-eye\"></i></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("editUser('%s')", user.Username)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"Edit User\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("editUser('%s')", user.Username)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><i class=\"fas fa-edit\"></i></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("manageAccessKeys('%s')", user.Username)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<button type=\"button\" class=\"btn btn-outline-warning btn-sm\" title=\"Manage Keys\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("manageAccessKeys('%s')", user.Username)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><i class=\"fas fa-key\"></i></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("deleteUser('%s')", user.Username)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete User\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("deleteUser('%s')", user.Username)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(data.Users) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<tr><td colspan=\"4\" class=\"text-center text-muted py-4\"><i class=\"fas fa-users fa-3x mb-3 text-muted\"></i><div><h5>No users found</h5><p>Create your first object store user to get started.</p></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table></div></div></div></div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 188, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</small></div></div></div><!-- Create User Modal --><div class=\"modal fade\" id=\"createUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-plus me-2\"></i>Create New User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"createUserForm\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username *</label> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div><div class=\"mb-3\"><label for=\"email\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"email\" name=\"email\"></div><div class=\"mb-3\"><label for=\"actions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"actions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple permissions</small></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"generateKey\" name=\"generateKey\" checked> <label class=\"form-check-label\" for=\"generateKey\">Generate access key automatically</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleCreateUser()\">Create User</button></div></div></div></div><!-- Edit User Modal --><div class=\"modal fade\" id=\"editUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-edit me-2\"></i>Edit User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"editUserForm\"><input type=\"hidden\" id=\"editUsername\" name=\"username\"><div class=\"mb-3\"><label for=\"editEmail\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"editEmail\" name=\"email\"></div><div class=\"mb-3\"><label for=\"editActions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"editActions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleUpdateUser()\">Update User</button></div></div></div></div><!-- User Details Modal --><div class=\"modal fade\" id=\"userDetailsModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user me-2\"></i>User Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\" id=\"userDetailsContent\"><!-- Content will be loaded dynamically --></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- Access Keys Management Modal --><div class=\"modal fade\" id=\"accessKeysModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-key me-2\"></i>Manage Access Keys</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><div class=\"d-flex justify-content-between align-items-center mb-3\"><h6>Access Keys for <span id=\"accessKeysUsername\"></span></h6><button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createAccessKey()\"><i class=\"fas fa-plus me-1\"></i>Create New Key</button></div><div id=\"accessKeysContent\"><!-- Content will be loaded dynamically --></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for user management --><script>\n // User management functions will be included in admin.js\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Helper functions for template
var _ = templruntime.GeneratedTemplate
+391
View File
@@ -0,0 +1,391 @@
package app
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ S3Buckets(data dash.S3BucketsData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">
<i class="fas fa-cube me-2"></i>Object Store Buckets
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-primary"
data-bs-toggle="modal"
data-bs-target="#createBucketModal">
<i class="fas fa-plus me-1"></i>Create Bucket
</button>
</div>
</div>
</div>
<div id="s3-buckets-content">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Buckets
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{fmt.Sprintf("%d", data.TotalBuckets)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-cube fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
Total Storage
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
{formatBytes(data.TotalSize)}
</div>
</div>
<div class="col-auto">
<i class="fas fa-hdd fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Last Updated
</div>
<div class="h6 mb-0 font-weight-bold text-gray-800">
{data.LastUpdated.Format("15:04:05")}
</div>
</div>
<div class="col-auto">
<i class="fas fa-clock fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Buckets Table -->
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-cube me-2"></i>Object Store Buckets
</h6>
<div class="dropdown no-arrow">
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i>
</a>
<div class="dropdown-menu dropdown-menu-right shadow animated--fade-in">
<div class="dropdown-header">Actions:</div>
<a class="dropdown-item" href="#" onclick="exportBucketList()">
<i class="fas fa-download me-2"></i>Export List
</a>
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" width="100%" cellspacing="0" id="bucketsTable">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Objects</th>
<th>Size</th>
<th>Quota</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, bucket := range data.Buckets {
<tr>
<td>
<a href={templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name))}
class="text-decoration-none">
<i class="fas fa-cube me-2"></i>
{bucket.Name}
</a>
</td>
<td>{bucket.CreatedAt.Format("2006-01-02 15:04")}</td>
<td>{fmt.Sprintf("%d", bucket.ObjectCount)}</td>
<td>{formatBytes(bucket.Size)}</td>
<td>
if bucket.Quota > 0 {
<div>
<span class={fmt.Sprintf("badge bg-%s", getQuotaStatusColor(bucket.Size, bucket.Quota, bucket.QuotaEnabled))}>
{formatBytes(bucket.Quota)}
</span>
if bucket.QuotaEnabled {
<div class="small text-muted">
{fmt.Sprintf("%.1f%% used", float64(bucket.Size)/float64(bucket.Quota)*100)}
</div>
} else {
<div class="small text-muted">Disabled</div>
}
</div>
} else {
<span class="text-muted">No quota</span>
}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href={templ.SafeURL(fmt.Sprintf("/files?path=/buckets/%s", bucket.Name))}
class="btn btn-outline-success btn-sm"
title="Browse Files">
<i class="fas fa-folder-open"></i>
</a>
<a href={templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name))}
class="btn btn-outline-primary btn-sm"
title="View Details">
<i class="fas fa-eye"></i>
</a>
<button type="button"
class="btn btn-outline-warning btn-sm quota-btn"
data-bucket-name={bucket.Name}
data-current-quota={fmt.Sprintf("%d", getQuotaInMB(bucket.Quota))}
data-quota-enabled={fmt.Sprintf("%t", bucket.QuotaEnabled)}
title="Manage Quota">
<i class="fas fa-tachometer-alt"></i>
</button>
<button type="button"
class="btn btn-outline-danger btn-sm delete-bucket-btn"
data-bucket-name={bucket.Name}
title="Delete Bucket">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
if len(data.Buckets) == 0 {
<tr>
<td colspan="6" class="text-center text-muted py-4">
<i class="fas fa-cube fa-3x mb-3 text-muted"></i>
<div>
<h5>No Object Store buckets found</h5>
<p>Create your first bucket to get started with S3 storage.</p>
<button type="button" class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#createBucketModal">
<i class="fas fa-plus me-1"></i>Create Bucket
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}
</small>
</div>
</div>
</div>
<!-- Create Bucket Modal -->
<div class="modal fade" id="createBucketModal" tabindex="-1" aria-labelledby="createBucketModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createBucketModalLabel">
<i class="fas fa-plus me-2"></i>Create New S3 Bucket
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="createBucketForm">
<div class="modal-body">
<div class="mb-3">
<label for="bucketName" class="form-label">Bucket Name</label>
<input type="text" class="form-control" id="bucketName" name="name"
placeholder="my-bucket-name" required
pattern="[a-z0-9.-]+"
title="Bucket name must contain only lowercase letters, numbers, dots, and hyphens">
<div class="form-text">
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="enableQuota" name="quota_enabled">
<label class="form-check-label" for="enableQuota">
Enable Storage Quota
</label>
</div>
</div>
<div class="mb-3" id="quotaSettings" style="display: none;">
<div class="row">
<div class="col-md-8">
<label for="quotaSize" class="form-label">Quota Size</label>
<input type="number" class="form-control" id="quotaSize" name="quota_size"
placeholder="1024" min="1" step="1">
</div>
<div class="col-md-4">
<label for="quotaUnit" class="form-label">Unit</label>
<select class="form-select" id="quotaUnit" name="quota_unit">
<option value="MB" selected>MB</option>
<option value="GB">GB</option>
<option value="TB">TB</option>
</select>
</div>
</div>
<div class="form-text">
Set the maximum storage size for this bucket.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>Create Bucket
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteBucketModal" tabindex="-1" aria-labelledby="deleteBucketModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteBucketModalLabel">
<i class="fas fa-exclamation-triangle me-2 text-warning"></i>Delete Bucket
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the bucket <strong id="deleteBucketName"></strong>?</p>
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="deleteBucket()">
<i class="fas fa-trash me-1"></i>Delete Bucket
</button>
</div>
</div>
</div>
</div>
<!-- Manage Quota Modal -->
<div class="modal fade" id="manageQuotaModal" tabindex="-1" aria-labelledby="manageQuotaModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="manageQuotaModalLabel">
<i class="fas fa-tachometer-alt me-2"></i>Manage Bucket Quota
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="quotaForm">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Bucket Name</label>
<input type="text" class="form-control" id="quotaBucketName" readonly>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="quotaEnabled" name="quota_enabled">
<label class="form-check-label" for="quotaEnabled">
Enable Storage Quota
</label>
</div>
</div>
<div class="mb-3" id="quotaSizeSettings">
<div class="row">
<div class="col-md-8">
<label for="quotaSizeMB" class="form-label">Quota Size</label>
<input type="number" class="form-control" id="quotaSizeMB" name="quota_size"
placeholder="1024" min="0" step="1">
</div>
<div class="col-md-4">
<label for="quotaUnitMB" class="form-label">Unit</label>
<select class="form-select" id="quotaUnitMB" name="quota_unit">
<option value="MB" selected>MB</option>
<option value="GB">GB</option>
<option value="TB">TB</option>
</select>
</div>
</div>
<div class="form-text">
Set the maximum storage size for this bucket. Set to 0 to remove quota.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="fas fa-save me-1"></i>Update Quota
</button>
</div>
</form>
</div>
</div>
</div>
}
// Helper functions for template
func getQuotaStatusColor(used, quota int64, enabled bool) string {
if !enabled || quota <= 0 {
return "secondary"
}
percentage := float64(used) / float64(quota) * 100
if percentage >= 90 {
return "danger"
} else if percentage >= 75 {
return "warning"
} else {
return "success"
}
}
func getQuotaInMB(quotaBytes int64) int64 {
if quotaBytes < 0 {
quotaBytes = -quotaBytes // Handle disabled quotas (negative values)
}
return quotaBytes / (1024 * 1024)
}
File diff suppressed because one or more lines are too long
+160
View File
@@ -0,0 +1,160 @@
package app
import (
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
)
templ TaskConfig(data *maintenance.TaskConfigData) {
<div class="container-fluid">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h2 class="mb-0">
<i class={data.TaskIcon + " me-2"}></i>
{data.TaskName} Configuration
</h2>
<div class="btn-group">
<a href="/maintenance/config" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>
Back to Configuration
</a>
<a href="/maintenance" class="btn btn-outline-primary">
<i class="fas fa-list me-1"></i>
View Queue
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class={data.TaskIcon + " me-2"}></i>
{data.TaskName} Settings
</h5>
</div>
<div class="card-body">
<p class="text-muted mb-4">{data.Description}</p>
<!-- Task-specific configuration form -->
<form method="POST">
<div class="task-config-form">
@templ.Raw(string(data.ConfigFormHTML))
</div>
<hr class="my-4">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>
Save Configuration
</button>
<button type="button" class="btn btn-secondary" onclick="resetForm()">
<i class="fas fa-undo me-1"></i>
Reset to Defaults
</button>
<a href="/maintenance/config" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i>
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Task Information -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle me-2"></i>
Task Information
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-muted">Task Type</h6>
<p class="mb-3">
<span class="badge bg-secondary">{string(data.TaskType)}</span>
</p>
</div>
<div class="col-md-6">
<h6 class="text-muted">Display Name</h6>
<p class="mb-3">{data.TaskName}</p>
</div>
</div>
<div class="row">
<div class="col-12">
<h6 class="text-muted">Description</h6>
<p class="mb-0">{data.Description}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function resetForm() {
if (confirm('Are you sure you want to reset all settings to their default values?')) {
// Find all form inputs and reset them
const form = document.querySelector('form');
if (form) {
form.reset();
}
}
}
// Auto-save form data to localStorage for recovery
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
if (form) {
const taskType = '{string(data.TaskType)}';
const storageKey = 'taskConfig_' + taskType;
// Load saved data
const savedData = localStorage.getItem(storageKey);
if (savedData) {
try {
const data = JSON.parse(savedData);
Object.keys(data).forEach(key => {
const input = form.querySelector(`[name="${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = data[key];
} else {
input.value = data[key];
}
}
});
} catch (e) {
console.warn('Failed to load saved configuration:', e);
}
}
// Save data on input change
form.addEventListener('input', function() {
const formData = new FormData(form);
const data = {};
for (let [key, value] of formData.entries()) {
data[key] = value;
}
localStorage.setItem(storageKey, JSON.stringify(data));
});
// Clear saved data on successful submit
form.addEventListener('submit', function() {
localStorage.removeItem(storageKey);
});
}
});
</script>
}
+174
View File
@@ -0,0 +1,174 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
)
func TaskConfig(data *maintenance.TaskConfigData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container-fluid\"><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center\"><h2 class=\"mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 = []any{data.TaskIcon + " me-2"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.TaskName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 14, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " Configuration</h2><div class=\"btn-group\"><a href=\"/maintenance/config\" class=\"btn btn-outline-secondary\"><i class=\"fas fa-arrow-left me-1\"></i> Back to Configuration</a> <a href=\"/maintenance\" class=\"btn btn-outline-primary\"><i class=\"fas fa-list me-1\"></i> View Queue</a></div></div></div></div><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 = []any{data.TaskIcon + " me-2"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.TaskName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 36, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " Settings</h5></div><div class=\"card-body\"><p class=\"text-muted mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 40, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p><!-- Task-specific configuration form --><form method=\"POST\"><div class=\"task-config-form\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(string(data.ConfigFormHTML)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><hr class=\"my-4\"><div class=\"d-flex gap-2\"><button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i> Save Configuration</button> <button type=\"button\" class=\"btn btn-secondary\" onclick=\"resetForm()\"><i class=\"fas fa-undo me-1\"></i> Reset to Defaults</button> <a href=\"/maintenance/config\" class=\"btn btn-outline-secondary\"><i class=\"fas fa-times me-1\"></i> Cancel</a></div></form></div></div></div></div><!-- Task Information --><div class=\"row mt-4\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"fas fa-info-circle me-2\"></i> Task Information</h5></div><div class=\"card-body\"><div class=\"row\"><div class=\"col-md-6\"><h6 class=\"text-muted\">Task Type</h6><p class=\"mb-3\"><span class=\"badge bg-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(string(data.TaskType))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 85, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></p></div><div class=\"col-md-6\"><h6 class=\"text-muted\">Display Name</h6><p class=\"mb-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.TaskName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 90, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div><div class=\"row\"><div class=\"col-12\"><h6 class=\"text-muted\">Description</h6><p class=\"mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 96, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p></div></div></div></div></div></div></div><script>\n function resetForm() {\n if (confirm('Are you sure you want to reset all settings to their default values?')) {\n // Find all form inputs and reset them\n const form = document.querySelector('form');\n if (form) {\n form.reset();\n }\n }\n }\n\n // Auto-save form data to localStorage for recovery\n document.addEventListener('DOMContentLoaded', function() {\n const form = document.querySelector('form');\n if (form) {\n const taskType = '{string(data.TaskType)}';\n const storageKey = 'taskConfig_' + taskType;\n\n // Load saved data\n const savedData = localStorage.getItem(storageKey);\n if (savedData) {\n try {\n const data = JSON.parse(savedData);\n Object.keys(data).forEach(key => {\n const input = form.querySelector(`[name=\"${key}\"]`);\n if (input) {\n if (input.type === 'checkbox') {\n input.checked = data[key];\n } else {\n input.value = data[key];\n }\n }\n });\n } catch (e) {\n console.warn('Failed to load saved configuration:', e);\n }\n }\n\n // Save data on input change\n form.addEventListener('input', function() {\n const formData = new FormData(form);\n const data = {};\n for (let [key, value] of formData.entries()) {\n data[key] = value;\n }\n localStorage.setItem(storageKey, JSON.stringify(data));\n });\n\n // Clear saved data on successful submit\n form.addEventListener('submit', function() {\n localStorage.removeItem(storageKey);\n });\n }\n });\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+160
View File
@@ -0,0 +1,160 @@
package app
import (
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
"github.com/seaweedfs/seaweedfs/weed/admin/view/components"
)
// TaskConfigTemplData represents data for templ-based task configuration
type TaskConfigTemplData struct {
TaskType maintenance.MaintenanceTaskType
TaskName string
TaskIcon string
Description string
ConfigSections []components.ConfigSectionData
}
templ TaskConfigTempl(data *TaskConfigTemplData) {
<div class="container-fluid">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h2 class="mb-0">
<i class={data.TaskIcon + " me-2"}></i>
{data.TaskName} Configuration
</h2>
<div class="btn-group">
<a href="/maintenance/config" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>
Back to Configuration
</a>
<a href="/maintenance/queue" class="btn btn-outline-info">
<i class="fas fa-list me-1"></i>
View Queue
</a>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle me-2"></i>
{data.Description}
</div>
</div>
</div>
<form method="POST" class="needs-validation" novalidate>
<!-- Render all configuration sections -->
for _, section := range data.ConfigSections {
@components.ConfigSection(section)
}
<!-- Form actions -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>
Save Configuration
</button>
<button type="button" class="btn btn-outline-secondary ms-2" onclick="resetForm()">
<i class="fas fa-undo me-1"></i>
Reset
</button>
</div>
<div>
<button type="button" class="btn btn-outline-info" onclick="testConfiguration()">
<i class="fas fa-play me-1"></i>
Test Configuration
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<script>
// Form validation
(function() {
'use strict';
window.addEventListener('load', function() {
var forms = document.getElementsByClassName('needs-validation');
var validation = Array.prototype.filter.call(forms, function(form) {
form.addEventListener('submit', function(event) {
if (form.checkValidity() === false) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
}, false);
});
}, false);
})();
// Auto-save functionality
let autoSaveTimeout;
function autoSave() {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(function() {
const formData = new FormData(document.querySelector('form'));
localStorage.setItem('task_config_' + '{data.TaskType}', JSON.stringify(Object.fromEntries(formData)));
}, 1000);
}
// Add auto-save listeners to all form inputs
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
if (form) {
form.addEventListener('input', autoSave);
form.addEventListener('change', autoSave);
}
});
// Reset form function
function resetForm() {
if (confirm('Are you sure you want to reset all changes?')) {
location.reload();
}
}
// Test configuration function
function testConfiguration() {
const formData = new FormData(document.querySelector('form'));
// Show loading state
const testBtn = document.querySelector('button[onclick="testConfiguration()"]');
const originalContent = testBtn.innerHTML;
testBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Testing...';
testBtn.disabled = true;
fetch('/maintenance/config/{data.TaskType}/test', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Configuration test successful!');
} else {
alert('Configuration test failed: ' + data.error);
}
})
.catch(error => {
alert('Test failed: ' + error);
})
.finally(() => {
testBtn.innerHTML = originalContent;
testBtn.disabled = false;
});
}
</script>
}
@@ -0,0 +1,112 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
"github.com/seaweedfs/seaweedfs/weed/admin/view/components"
)
// TaskConfigTemplData represents data for templ-based task configuration
type TaskConfigTemplData struct {
TaskType maintenance.MaintenanceTaskType
TaskName string
TaskIcon string
Description string
ConfigSections []components.ConfigSectionData
}
func TaskConfigTempl(data *TaskConfigTemplData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container-fluid\"><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center\"><h2 class=\"mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 = []any{data.TaskIcon + " me-2"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_templ.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.TaskName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_templ.templ`, Line: 24, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " Configuration</h2><div class=\"btn-group\"><a href=\"/maintenance/config\" class=\"btn btn-outline-secondary\"><i class=\"fas fa-arrow-left me-1\"></i> Back to Configuration</a> <a href=\"/maintenance/queue\" class=\"btn btn-outline-info\"><i class=\"fas fa-list me-1\"></i> View Queue</a></div></div></div></div><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"alert alert-info\" role=\"alert\"><i class=\"fas fa-info-circle me-2\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_templ.templ`, Line: 44, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div></div><form method=\"POST\" class=\"needs-validation\" novalidate><!-- Render all configuration sections -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, section := range data.ConfigSections {
templ_7745c5c3_Err = components.ConfigSection(section).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Form actions --><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-body\"><div class=\"d-flex justify-content-between\"><div><button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i> Save Configuration</button> <button type=\"button\" class=\"btn btn-outline-secondary ms-2\" onclick=\"resetForm()\"><i class=\"fas fa-undo me-1\"></i> Reset</button></div><div><button type=\"button\" class=\"btn btn-outline-info\" onclick=\"testConfiguration()\"><i class=\"fas fa-play me-1\"></i> Test Configuration</button></div></div></div></div></div></div></form></div><script>\n // Form validation\n (function() {\n 'use strict';\n window.addEventListener('load', function() {\n var forms = document.getElementsByClassName('needs-validation');\n var validation = Array.prototype.filter.call(forms, function(form) {\n form.addEventListener('submit', function(event) {\n if (form.checkValidity() === false) {\n event.preventDefault();\n event.stopPropagation();\n }\n form.classList.add('was-validated');\n }, false);\n });\n }, false);\n })();\n\n // Auto-save functionality\n let autoSaveTimeout;\n function autoSave() {\n clearTimeout(autoSaveTimeout);\n autoSaveTimeout = setTimeout(function() {\n const formData = new FormData(document.querySelector('form'));\n localStorage.setItem('task_config_' + '{data.TaskType}', JSON.stringify(Object.fromEntries(formData)));\n }, 1000);\n }\n\n // Add auto-save listeners to all form inputs\n document.addEventListener('DOMContentLoaded', function() {\n const form = document.querySelector('form');\n if (form) {\n form.addEventListener('input', autoSave);\n form.addEventListener('change', autoSave);\n }\n });\n\n // Reset form function\n function resetForm() {\n if (confirm('Are you sure you want to reset all changes?')) {\n location.reload();\n }\n }\n\n // Test configuration function\n function testConfiguration() {\n const formData = new FormData(document.querySelector('form'));\n \n // Show loading state\n const testBtn = document.querySelector('button[onclick=\"testConfiguration()\"]');\n const originalContent = testBtn.innerHTML;\n testBtn.innerHTML = '<i class=\"fas fa-spinner fa-spin me-1\"></i>Testing...';\n testBtn.disabled = true;\n \n fetch('/maintenance/config/{data.TaskType}/test', {\n method: 'POST',\n body: formData\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Configuration test successful!');\n } else {\n alert('Configuration test failed: ' + data.error);\n }\n })\n .catch(error => {\n alert('Test failed: ' + error);\n })\n .finally(() => {\n testBtn.innerHTML = originalContent;\n testBtn.disabled = false;\n });\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+68
View File
@@ -0,0 +1,68 @@
package app
import (
"fmt"
"strconv"
)
// getStatusColor returns Bootstrap color class for status
func getStatusColor(status string) string {
switch status {
case "active", "healthy":
return "success"
case "warning":
return "warning"
case "critical", "unreachable":
return "danger"
default:
return "secondary"
}
}
// formatBytes converts bytes to human readable format
func formatBytes(bytes int64) string {
if bytes == 0 {
return "0 B"
}
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
var i int
value := float64(bytes)
for value >= 1024 && i < len(units)-1 {
value /= 1024
i++
}
if i == 0 {
return fmt.Sprintf("%.0f %s", value, units[i])
}
return fmt.Sprintf("%.1f %s", value, units[i])
}
// formatNumber formats large numbers with commas
func formatNumber(num int64) string {
if num == 0 {
return "0"
}
str := strconv.FormatInt(num, 10)
result := ""
for i, char := range str {
if i > 0 && (len(str)-i)%3 == 0 {
result += ","
}
result += string(char)
}
return result
}
// calculatePercent calculates percentage for progress bars
func calculatePercent(current, max int) int {
if max == 0 {
return 0
}
return (current * 100) / max
}
+497
View File
@@ -0,0 +1,497 @@
package app
import (
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ VolumeDetails(data dash.VolumeDetailsData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<div>
<h1 class="h2">
<i class="fas fa-database me-2"></i>Volume Details
</h1>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/admin" class="text-decoration-none">Dashboard</a></li>
<li class="breadcrumb-item"><a href="/cluster/volumes" class="text-decoration-none">Volumes</a></li>
<li class="breadcrumb-item active" aria-current="page">Volume {fmt.Sprintf("%d", data.Volume.Id)}</li>
</ol>
</nav>
</div>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="history.back()">
<i class="fas fa-arrow-left me-1"></i>Back
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="window.location.reload()">
<i class="fas fa-refresh me-1"></i>Refresh
</button>
</div>
</div>
</div>
<div class="row">
<!-- Volume Information Card -->
<div class="col-lg-8">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-info-circle me-2"></i>Volume Information
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label"><strong>Volume ID:</strong></label>
<div><code class="fs-5">{fmt.Sprintf("%d", data.Volume.Id)}</code></div>
</div>
<div class="mb-3">
<label class="form-label"><strong>Server:</strong></label>
<div>
<a href={templ.SafeURL(fmt.Sprintf("http://%s/ui/index.html", data.Volume.Server))} target="_blank" class="text-decoration-none">
{data.Volume.Server}
<i class="fas fa-external-link-alt ms-1 text-muted"></i>
</a>
</div>
</div>
<div class="mb-3">
<label class="form-label"><strong>Data Center:</strong></label>
<div><span class="badge bg-light text-dark">{data.Volume.DataCenter}</span></div>
</div>
<div class="mb-3">
<label class="form-label"><strong>Rack:</strong></label>
<div><span class="badge bg-light text-dark">{data.Volume.Rack}</span></div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label"><strong>Collection:</strong></label>
<div>
if data.Volume.Collection == "" {
<a href={templ.SafeURL("/cluster/volumes?collection=default")} class="text-decoration-none">
<span class="badge bg-secondary">default</span>
</a>
} else {
<a href={templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", data.Volume.Collection))} class="text-decoration-none">
<span class="badge bg-secondary">{data.Volume.Collection}</span>
</a>
}
</div>
</div>
<div class="mb-3">
<label class="form-label"><strong>Replication:</strong></label>
<div><span class="badge bg-info">{fmt.Sprintf("%03d", data.Volume.ReplicaPlacement)}</span></div>
</div>
<div class="mb-3">
<label class="form-label"><strong>Disk Type:</strong></label>
<div>
<span class="badge bg-primary">
if data.Volume.DiskType == "" {
hdd
} else {
{data.Volume.DiskType}
}
</span>
</div>
</div>
<div class="mb-3">
<label class="form-label"><strong>Version:</strong></label>
<div><span class="badge bg-dark">{fmt.Sprintf("v%d", data.Volume.Version)}</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Statistics Card -->
<div class="col-lg-4">
<!-- Volume Statistics & Health Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-chart-pie me-2"></i>Volume Statistics & Health
</h6>
</div>
<div class="card-body">
<!-- Storage Metrics -->
<div class="row mb-3">
<div class="col-6">
<div class="text-center">
<div class="h4 mb-0 font-weight-bold text-success">
{formatBytes(int64(data.Volume.Size - data.Volume.DeletedByteCount))}
</div>
<small class="text-muted">Active Bytes</small>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="h4 mb-0 font-weight-bold text-danger">
{formatBytes(int64(data.Volume.DeletedByteCount))}
</div>
<small class="text-muted">Deleted Bytes</small>
</div>
</div>
</div>
<!-- File Metrics -->
<div class="row mb-3">
<div class="col-6">
<div class="text-center">
<div class="h4 mb-0 font-weight-bold text-success">
{fmt.Sprintf("%d", data.Volume.FileCount)}
</div>
<small class="text-muted">Active Files</small>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="h4 mb-0 font-weight-bold text-danger">
{fmt.Sprintf("%d", data.Volume.DeleteCount)}
</div>
<small class="text-muted">Deleted Files</small>
</div>
</div>
</div>
<!-- Storage Efficiency -->
if data.Volume.FileCount > 0 && data.Volume.Size > 0 {
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Storage Efficiency</small>
<small class="text-muted">
{fmt.Sprintf("%.1f%%", float64(data.Volume.Size-data.Volume.DeletedByteCount)/float64(data.Volume.Size)*100)}
</small>
</div>
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-info" role="progressbar"
style={fmt.Sprintf("width: %.1f%%", float64(data.Volume.Size-data.Volume.DeletedByteCount)/float64(data.Volume.Size)*100)}
aria-valuenow={fmt.Sprintf("%.1f", float64(data.Volume.Size-data.Volume.DeletedByteCount)/float64(data.Volume.Size)*100)}
aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
}
<hr class="my-3">
<!-- Status & Configuration -->
<div class="row mb-3">
<div class="col-12">
<div class="text-center mb-2">
if data.Volume.ReadOnly {
<span class="badge bg-warning fs-6 px-3 py-2">
<i class="fas fa-lock me-1"></i>Read Only
</span>
if data.Volume.Size >= data.VolumeSizeLimit {
<div class="mt-1">
<small class="text-muted">Size limit exceeded</small>
</div>
}
} else if data.VolumeSizeLimit > data.Volume.Size {
<span class="badge bg-success fs-6 px-3 py-2">
<i class="fas fa-edit me-1"></i>Read/Write
</span>
} else {
<span class="badge bg-warning fs-6 px-3 py-2">
<i class="fas fa-exclamation-triangle me-1"></i>Size Limit Reached
</span>
}
</div>
</div>
</div>
<!-- Maintenance Info -->
<div class="row mb-3">
<div class="col-6">
<div class="text-center">
<div class="h6 mb-0 font-weight-bold text-info">
#{fmt.Sprintf("%d", data.Volume.CompactRevision)}
</div>
<small class="text-muted">Vacuum Revision</small>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="h6 mb-0 font-weight-bold text-secondary">
if data.Volume.ModifiedAtSecond > 0 {
{formatTimestamp(data.Volume.ModifiedAtSecond)}
} else {
<span class="text-muted">Never modified</span>
}
</div>
<small class="text-muted">Last Modified</small>
</div>
</div>
</div>
<!-- TTL Configuration -->
if data.Volume.Ttl > 0 {
<div class="mb-3 text-center">
<span class="badge bg-info fs-6 px-3 py-2">
<i class="fas fa-clock me-1"></i>{formatTTL(data.Volume.Ttl)}
</span>
<div class="mt-1">
<small class="text-muted">Time To Live</small>
</div>
</div>
}
<!-- Remote Storage Configuration -->
if data.Volume.RemoteStorageName != "" {
<hr class="my-3">
<div class="mb-2">
<div class="text-center">
<div class="h6 mb-1 font-weight-bold text-info">
<i class="fas fa-cloud me-1"></i>{data.Volume.RemoteStorageName}
</div>
<small class="text-muted">Remote Storage</small>
</div>
</div>
if data.Volume.RemoteStorageKey != "" {
<div class="text-center">
<div class="text-xs font-monospace bg-light p-2 rounded text-truncate" title={data.Volume.RemoteStorageKey}>
{data.Volume.RemoteStorageKey}
</div>
<small class="text-muted">Storage Key</small>
</div>
}
}
</div>
</div>
</div>
</div>
<!-- Replicas Card -->
if len(data.Replicas) > 0 {
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-copy me-2"></i>Replicas ({fmt.Sprintf("%d", data.ReplicationCount)})
</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Server</th>
<th>Data Center</th>
<th>Rack</th>
<th>Size</th>
<th>File Count</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Primary Volume (current one) -->
<tr class="table-primary">
<td>
<strong>
<a href={templ.SafeURL(fmt.Sprintf("http://%s/ui/index.html", data.Volume.Server))} target="_blank" class="text-decoration-none">
{data.Volume.Server}
<i class="fas fa-external-link-alt ms-1 text-muted"></i>
</a>
</strong>
<span class="badge bg-success ms-2">Primary</span>
</td>
<td><span class="badge bg-light text-dark">{data.Volume.DataCenter}</span></td>
<td><span class="badge bg-light text-dark">{data.Volume.Rack}</span></td>
<td>{formatBytes(int64(data.Volume.Size))}</td>
<td>{fmt.Sprintf("%d", data.Volume.FileCount)}</td>
<td><span class="badge bg-success">Active</span></td>
<td>
<span class="text-muted">Current Volume</span>
</td>
</tr>
<!-- Replica Volumes -->
for _, replica := range data.Replicas {
<tr>
<td>
<a href={templ.SafeURL(fmt.Sprintf("http://%s/ui/index.html", replica.Server))} target="_blank" class="text-decoration-none">
{replica.Server}
<i class="fas fa-external-link-alt ms-1 text-muted"></i>
</a>
</td>
<td><span class="badge bg-light text-dark">{replica.DataCenter}</span></td>
<td><span class="badge bg-light text-dark">{replica.Rack}</span></td>
<td>{formatBytes(int64(replica.Size))}</td>
<td>{fmt.Sprintf("%d", replica.FileCount)}</td>
<td><span class="badge bg-info">Replica</span></td>
<td>
<a href={templ.SafeURL(fmt.Sprintf("/cluster/volumes/%d/%s", replica.Id, replica.Server))} class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye me-1"></i>View
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
}
<!-- Actions Card -->
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-tools me-2"></i>Actions
</h6>
</div>
<div class="card-body">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-danger vacuum-btn"
title="Vacuum Volume"
data-volume-id={fmt.Sprintf("%d", data.Volume.Id)}
data-server={data.Volume.Server}>
<i class="fas fa-compress-alt me-1"></i>Vacuum
</button>
</div>
<div class="mt-3">
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
Use these actions to perform maintenance operations on the volume.
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Last Updated -->
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}
</small>
</div>
</div>
<!-- JavaScript for volume actions -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add click handler for vacuum button
const vacuumBtn = document.querySelector('.vacuum-btn');
if (vacuumBtn) {
vacuumBtn.addEventListener('click', function() {
const volumeId = this.getAttribute('data-volume-id');
const server = this.getAttribute('data-server');
performVacuum(volumeId, server, this);
});
}
});
function performVacuum(volumeId, server, button) {
// Disable button and show loading state
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Vacuuming...';
// Send vacuum request
fetch(`/api/volumes/${volumeId}/${encodeURIComponent(server)}/vacuum`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.error) {
showMessage(data.error, 'error');
} else {
showMessage(data.message || 'Volume vacuum started successfully', 'success');
// Optionally refresh the page after a delay
setTimeout(() => {
window.location.reload();
}, 2000);
}
})
.catch(error => {
console.error('Error:', error);
showMessage('Failed to start vacuum operation', 'error');
})
.finally(() => {
// Re-enable button
button.disabled = false;
button.innerHTML = originalText;
});
}
function showMessage(message, type) {
// Create toast notification
const toast = document.createElement('div');
toast.className = `alert alert-${type === 'error' ? 'danger' : 'success'} alert-dismissible fade show position-fixed`;
toast.style.top = '20px';
toast.style.right = '20px';
toast.style.zIndex = '9999';
toast.style.minWidth = '300px';
toast.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 5000);
}
</script>
}
func formatTimestamp(unixTimestamp int64) string {
if unixTimestamp <= 0 {
return "Never"
}
t := time.Unix(unixTimestamp, 0)
return t.Format("2006-01-02 15:04:05")
}
func formatTTL(ttlSeconds uint32) string {
if ttlSeconds == 0 {
return "No TTL"
}
duration := time.Duration(ttlSeconds) * time.Second
// Convert to human readable format
days := int(duration.Hours()) / 24
hours := int(duration.Hours()) % 24
minutes := int(duration.Minutes()) % 60
if days > 0 {
if hours > 0 {
return fmt.Sprintf("%dd %dh", days, hours)
}
return fmt.Sprintf("%d days", days)
} else if hours > 0 {
if minutes > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%d hours", hours)
} else if minutes > 0 {
return fmt.Sprintf("%d minutes", minutes)
} else {
return fmt.Sprintf("%d seconds", int(duration.Seconds()))
}
}

Some files were not shown because too many files have changed in this diff Show More