Merge pull request 'feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard' (#3) from feat/site into main
deploy-132 / deploy (push) Failing after 11s
deploy-132 / deploy (push) Failing after 11s
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
+208
-3
@@ -11,238 +11,416 @@ cloud.google.com/go/accessapproval v1.13.0 h1:kx3RQSS0VglTngQTPfywgVj+xFgre/vJou
|
||||
cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI=
|
||||
cloud.google.com/go/accesscontextmanager v1.14.0 h1:50ofyZiGo2yL3Wt1gZ0j0QnD9y3YrhhcwX0N08uS6KY=
|
||||
cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k=
|
||||
cloud.google.com/go/accesscontextmanager v1.15.0 h1:0baVgug9IFV8y5cIoD4iN/m/QtJzk2k+j3MW+zJWCVw=
|
||||
cloud.google.com/go/accesscontextmanager v1.15.0/go.mod h1:YjW9urferk8i9ALwBF3bmdcogZeQYRn2yWwR8nkhsBc=
|
||||
cloud.google.com/go/aiplatform v1.125.0 h1:QUGv+XaHN9wcWdb0/J0NFIcaP/veQSvDcqg4GH6QiP4=
|
||||
cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI=
|
||||
cloud.google.com/go/aiplatform v1.126.0 h1:5PxeWpQfkAyN8mtgtVp4H5nP+ntwPLFMjNREVdiolR0=
|
||||
cloud.google.com/go/aiplatform v1.126.0/go.mod h1:iR3za3evdprLe1XL2pLu0cYVCuTbc87QG0pgvcgiJlE=
|
||||
cloud.google.com/go/analytics v0.35.0 h1:GuwmzJHIaQRvtko6g4wW1ngQ7rpDvDLZ8M3iP3kiflU=
|
||||
cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc=
|
||||
cloud.google.com/go/apigateway v1.12.0 h1:fpSMzMpRFOS3OAQBdiX3LIKNEGYe0ley2EI+lGaCK2s=
|
||||
cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk=
|
||||
cloud.google.com/go/apigateway v1.13.0 h1:IENNmFQlMsl58RlvYHf6wxBNQFeP+lAnVNcyxtmByTE=
|
||||
cloud.google.com/go/apigateway v1.13.0/go.mod h1:pvEpOuuOIw2ev9VCcOyVkDXHHL4lvgMuqIe7XjJ8JoU=
|
||||
cloud.google.com/go/apigeeconnect v1.12.0 h1:YvZhi0QkHobBRMrN6ri9PUPnnhgEd4VpW0+yo1WI7r0=
|
||||
cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q=
|
||||
cloud.google.com/go/apigeeregistry v1.0.0 h1:S0DHrbgpO8/b+YJY/Af2rEQ5d7dkiO1LB59UdvkS6Aw=
|
||||
cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0=
|
||||
cloud.google.com/go/apigeeregistry v1.1.0 h1:2zIogaYq7dF4E1dAGC4MUnasRuSmohsMl7wVuJ963/4=
|
||||
cloud.google.com/go/apigeeregistry v1.1.0/go.mod h1:4ZFhQlxMuyfDMz9ORDSV8FPZtf2yPQkKjigsFtrrE4Y=
|
||||
cloud.google.com/go/appengine v1.14.0 h1:dTww1xDqBpeR0BpLsiqfjyAnaK7S1vniJ5YR7L83Jh4=
|
||||
cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM=
|
||||
cloud.google.com/go/appengine v1.15.0 h1:0MiBM2KGk1WAhh489WoELQ7IeW2qw6HTE6nZIlrOw5U=
|
||||
cloud.google.com/go/appengine v1.15.0/go.mod h1:/8gGZsOX5GDjOo4mAWk8IV59p2991dxTbEtKIlhDjzU=
|
||||
cloud.google.com/go/area120 v0.15.0 h1:9v6HeQsBpfRqoZtNeLI4V8SQ7Jcmt6s2gmgbjl0fVFc=
|
||||
cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y=
|
||||
cloud.google.com/go/artifactregistry v1.25.0 h1:CWAoXkJBX02h68W7Z2ZNBqvH1wxET3s+fnKZcn2gM3c=
|
||||
cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8=
|
||||
cloud.google.com/go/artifactregistry v1.26.0 h1:iq5kkdY2FJY8RkkNghUelb3EOcqyy7dVhfDGVk/Xw9g=
|
||||
cloud.google.com/go/artifactregistry v1.26.0/go.mod h1:c5FPi5GtDBP+OAr5kKhCBNQDT9ZgAyobXQjekx93VWs=
|
||||
cloud.google.com/go/asset v1.27.0 h1:Lj2lg/FB7VIBAkvUTVVx7Z9HRSPyVw7WN9butFJSONg=
|
||||
cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M=
|
||||
cloud.google.com/go/asset v1.28.0 h1:M6YE1exBuZQhTi8wfIKrwjKK6b2ySfHKgrqXjPZA4EM=
|
||||
cloud.google.com/go/asset v1.28.0/go.mod h1:Pnvjhay8/FgodOH9uJC8OkfJfRtSnNIIU4WSxg5JfJw=
|
||||
cloud.google.com/go/assuredworkloads v1.18.0 h1:jk+W89a1UsdIzybt/UbRMRlJTXCqQaGuKCoFNz7nUb4=
|
||||
cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss=
|
||||
cloud.google.com/go/assuredworkloads v1.19.0 h1:XyNQ9SBX0eUkZYCZhC8dOY6ZFeSF6/OIjHn5dQs78Jo=
|
||||
cloud.google.com/go/assuredworkloads v1.19.0/go.mod h1:/UGGtFCMokM3sGJ4FxjfmLvvFpPa5I/Oz68mwk4Su+0=
|
||||
cloud.google.com/go/automl v1.20.0 h1:Gh9BlFogtzwSxaEfnx33XD8xJ+z0v33yLQd46/Ka7uM=
|
||||
cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI=
|
||||
cloud.google.com/go/automl v1.21.0 h1:brKXr4i9AMVfgbCKOYSu4rnIoz1ILIgcIAy3nerzEGc=
|
||||
cloud.google.com/go/automl v1.21.0/go.mod h1:MNbhUevuECzM3jqSOM7hmOedOdRJkm8xbbXW44SU15U=
|
||||
cloud.google.com/go/baremetalsolution v1.9.0 h1:c56Ygy+4Lr8WtnLG4nV2VIzhhGVgsa6gSEPdp83/PYI=
|
||||
cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg=
|
||||
cloud.google.com/go/baremetalsolution v1.10.0 h1:6MaZilXzGZJ3mGqbSCdDezs9yrze6kcmBABsRUZ/aYI=
|
||||
cloud.google.com/go/baremetalsolution v1.10.0/go.mod h1:xhhT9VQiKPFd2fUs4oeDSRrxV0sb0PGeVmuZoUE2cBA=
|
||||
cloud.google.com/go/batch v1.19.0 h1:i4xCFKCvzfkSldUPYWL+DgBpKVTC3N8DSK6E9rFSbqQ=
|
||||
cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw=
|
||||
cloud.google.com/go/batch v1.20.0 h1:nTL5x9HA1yVdPIdeqcbLjNnV9iYDYvMNmP6AnZ+qbHU=
|
||||
cloud.google.com/go/batch v1.20.0/go.mod h1:ABT/5QqsIDsONa+n/8C7XYPjwh/kjOEPXukcRTaMsCg=
|
||||
cloud.google.com/go/beyondcorp v1.7.0 h1:SHAZlC51z6ZO/OZZABnrI/Yk/z3GkhBREQC7qtgTo2I=
|
||||
cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY=
|
||||
cloud.google.com/go/beyondcorp v1.8.0 h1:rsyle6zjxce2s57BpULw5u170xS+KSjFENSY7C0MZbs=
|
||||
cloud.google.com/go/beyondcorp v1.8.0/go.mod h1:aVxzwamO8H4GXWQHowBAmL0KYNfYpW4E6Do2wfP0RYs=
|
||||
cloud.google.com/go/bigquery v1.77.0 h1:L5AW3jhzEKpFVg4i0mVHxKpxogrqT7dczWBSr4m9MKU=
|
||||
cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM=
|
||||
cloud.google.com/go/bigquery v1.79.0 h1:+tW6oRuP/4dOgdl/p32on0+3OktVQtU+veOMxhjoOhE=
|
||||
cloud.google.com/go/bigquery v1.79.0/go.mod h1:QTt5tgZxqqvZs3dOZKpvriGqy+CdvY9LyetirFZRPOE=
|
||||
cloud.google.com/go/bigtable v1.47.0 h1:NGLgDSr/i79BTGCjxH/maPKxyvl5q8/SsBsyLK52kdI=
|
||||
cloud.google.com/go/bigtable v1.47.0/go.mod h1:GUM6PdkG3rrDse9kugqvX5+ktwo3ldfLtLi1VFn5Wj4=
|
||||
cloud.google.com/go/bigtable v1.50.0 h1:lihc3U/eVrlIjK55i93K8sol+pKtFozIJ9vooEuNd4I=
|
||||
cloud.google.com/go/bigtable v1.50.0/go.mod h1:RTannV5mvoJM8KscLTfRYMPo84u9/j+C3PSyYJGf5Ic=
|
||||
cloud.google.com/go/billing v1.26.0 h1:6RRjbRd6iZKZFb7/MgRvmXKq/Ism02ckkZLJazj4CQ0=
|
||||
cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk=
|
||||
cloud.google.com/go/binaryauthorization v1.15.0 h1:yzkO2Hv1HHDs3+98Twtae9a9a2bEkufu7zTc9tRCiMc=
|
||||
cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss=
|
||||
cloud.google.com/go/binaryauthorization v1.16.0 h1:f1iNUaHWP9XPdh6grftTlIRuJSBytud4j51l1Jce94E=
|
||||
cloud.google.com/go/binaryauthorization v1.16.0/go.mod h1:E+iC5Avu4pdItdzGiSGHnh6TfQrl+KmPxDDg/T/VuHs=
|
||||
cloud.google.com/go/certificatemanager v1.14.0 h1:31fCXgMFDLSXh9HeF2M6hLE+dPF/1UFyIJXLmqpr41g=
|
||||
cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8=
|
||||
cloud.google.com/go/certificatemanager v1.15.0 h1:PwljEZlI3lZgoFUG0pnIBNOwrQUaAP+gS0ZahTwUZBA=
|
||||
cloud.google.com/go/certificatemanager v1.15.0/go.mod h1:8dfGG2/TbUpCNqsCF/TIMOGV0OVvU6nhkZWTU4MmCXU=
|
||||
cloud.google.com/go/channel v1.26.0 h1:lvEuQo7hmVsgedO9aLaIBvXRVg5EoK3jskKdYJl+Vyg=
|
||||
cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY=
|
||||
cloud.google.com/go/channel v1.27.0 h1:fqDZDzVw61c4iIUe7rQ+cBYlBdSZ3yqnzw5WrZTPjqs=
|
||||
cloud.google.com/go/channel v1.27.0/go.mod h1:9ekufBLXuQ6j1oyqtDSIp29qWU5EwCi8WUi9qkLn3MA=
|
||||
cloud.google.com/go/cloudbuild v1.30.0 h1:iOvtaQAcMmdLJaseR6qV76RgFHAAwZlwbpHwWMTqIdo=
|
||||
cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA=
|
||||
cloud.google.com/go/cloudbuild v1.32.0 h1:tlF+KSIJJ0kaKkxACpk9htZ1euDpyOqPuO1aHR4j3oI=
|
||||
cloud.google.com/go/cloudbuild v1.32.0/go.mod h1:mYgcM8CMaPmAnO7GxSQ9ADAxVRwS+1b7s6WVkt29OXY=
|
||||
cloud.google.com/go/clouddms v1.13.0 h1:/oIzRKf/FgUYqSBwSnwrrtJPkSQ2EMzY8UHQwhGXoJk=
|
||||
cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk=
|
||||
cloud.google.com/go/clouddms v1.14.0 h1:oaktwVyUeKTuzjpde3xbpmc9bAQPQq6WJWOBr7K6YEA=
|
||||
cloud.google.com/go/clouddms v1.14.0/go.mod h1:qSwET2Q27cJ4wCDsPsbkagXqQqkWfOy+gU3RjMsT/c8=
|
||||
cloud.google.com/go/cloudtasks v1.18.0 h1:KzT7hfix/9/xAf20tNPIxwX59XGpRF0Lun2t8LHOj9E=
|
||||
cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE=
|
||||
cloud.google.com/go/compute v1.62.0 h1:tJ7lKJ8YEVa6vZX03Jc8o1YePbjKDOQhDw1BscMZ1bs=
|
||||
cloud.google.com/go/compute v1.62.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw=
|
||||
cloud.google.com/go/compute v1.64.0 h1:7MmuzeAxlG5MOG5PQD2NLtyYR6bWjkvGljRu7pByoRU=
|
||||
cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/contactcenterinsights v1.22.0 h1:VzNZG5RxHhRWlhmPg3GHUmPDqsZXbHq1GJ9yw6ISJbY=
|
||||
cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ=
|
||||
cloud.google.com/go/contactcenterinsights v1.23.0 h1:EtrRf8T7ajR+dR8nseDxwKy3o1VaWP1GFAu7lgMmT9A=
|
||||
cloud.google.com/go/contactcenterinsights v1.23.0/go.mod h1:uB/kygbfYH/gWEq3NEgq3QRI7/MvpjFyX81ajcW5YAI=
|
||||
cloud.google.com/go/container v1.51.0 h1:KdeWHqwlOvABdhkhSTSb4QFySMOuXnc3UUoQMmq9lic=
|
||||
cloud.google.com/go/container v1.51.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs=
|
||||
cloud.google.com/go/container v1.53.0 h1:3zUySblDfZI9c+5gUWVOacKrdsQwiEerK4HM8eS/Ud8=
|
||||
cloud.google.com/go/container v1.53.0/go.mod h1:SBOylKhlKYCBFs/8kz2yqRdUW5ctVNHs82JKOTjrB9s=
|
||||
cloud.google.com/go/containeranalysis v0.19.0 h1:89ZhFvJHWzX9/jUJy/IEVbtSa2hv4+NtsvwWMEsUYnY=
|
||||
cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ=
|
||||
cloud.google.com/go/datacatalog v1.32.0 h1:fyYn8ODkGil5y3zTIqgIhOfzTu1ACaU2o+C750CO6Ac=
|
||||
cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM=
|
||||
cloud.google.com/go/datacatalog v1.33.0 h1:8V80PpoAGdOOr2QhBrp4wZ66MDCbATdAB/fmVmo5rlU=
|
||||
cloud.google.com/go/datacatalog v1.33.0/go.mod h1:/EMN04S73fZcPdtNg86VYLDrhi2HheMehQtMCS86Klk=
|
||||
cloud.google.com/go/dataflow v0.16.0 h1:BchGCAl9QIZ/pyTGokv5V3daxBTrXsOmrU4ARXoTNd4=
|
||||
cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ=
|
||||
cloud.google.com/go/dataform v1.0.0 h1:EExrLoU1kh8wYxjeRW/LUIlC4yk4QW5ikoZMbI0mgtE=
|
||||
cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk=
|
||||
cloud.google.com/go/dataform v1.2.0 h1:eAIsWhr6AbUIX+wOdfctO6hAr9UoQnTR2qQW739XH1I=
|
||||
cloud.google.com/go/dataform v1.2.0/go.mod h1:Lhkjd6L04/nBqsEo7S9Tx7D+Vm0pDDDZuKczewAJuX0=
|
||||
cloud.google.com/go/datafusion v1.13.0 h1:rpmpw3F9clEDTk1uCAMjPwJblRGjlW1tQEMEiTC/tR8=
|
||||
cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc=
|
||||
cloud.google.com/go/datafusion v1.14.0 h1:56rjOW8xFBnTOLgTEWdU09KhVC2/X+y/ukzux7zYqBE=
|
||||
cloud.google.com/go/datafusion v1.14.0/go.mod h1:2z+uDUKkLPacNNos5lW1Jf1IRDoFyeE+glJ4hmxF2Uc=
|
||||
cloud.google.com/go/datalabeling v0.14.0 h1:hlmO3GBCfiU23UovEKnJoKDKzr+7Du5x1UxXm+4U5AY=
|
||||
cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q=
|
||||
cloud.google.com/go/datalabeling v0.15.0 h1:9TL2kwlO/ODD1fne3uvgTLa53SttwrQi92Bn7KWnKhg=
|
||||
cloud.google.com/go/datalabeling v0.15.0/go.mod h1:H8WSRKD9XYCDXDlZE3bPgvV7UYI0F05e+ufKev2AFc8=
|
||||
cloud.google.com/go/dataplex v1.35.0 h1:EKEhiy/SGYwCH2DZ2r8JEFq1Hx+x+fjJZXRDY3rgPEk=
|
||||
cloud.google.com/go/dataplex v1.35.0/go.mod h1:B7AFwXU1u3sp7FVQ3IFYnQguGTycJS2mF1voE0lLe1o=
|
||||
cloud.google.com/go/dataplex v1.36.0 h1:r4O6RHEkwHr41ItsTUym0ZgH1iDf48R+W8fI7KoMNl8=
|
||||
cloud.google.com/go/dataplex v1.36.0/go.mod h1:ftgNMXBt+wJ4wPVNvYJ3UY3VTZtKS/i/uFEQppaEbKk=
|
||||
cloud.google.com/go/dataproc/v2 v2.23.0 h1:7PR3Aa+NO+AESWUv1dt6aFHfXizIx/zo6N2sdQuEWI0=
|
||||
cloud.google.com/go/dataproc/v2 v2.23.0/go.mod h1:dOzSynzBm7TBf9nIxmJxKAQt5EpdNPcNJomYfbpPhm4=
|
||||
cloud.google.com/go/dataproc/v2 v2.25.0 h1:IF1wxkXvcgoTP92NVqX6QNDf2SdKNZFPa2IWeSKP310=
|
||||
cloud.google.com/go/dataproc/v2 v2.25.0/go.mod h1:hkiM6kzc8CwLGoquMN1oghyhuI1fE0girmChH4h9W7w=
|
||||
cloud.google.com/go/dataqna v0.13.0 h1:VR1y/NuN+RvPkmfmlowbmk60BLINR4MgZAusVFIGQjU=
|
||||
cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs=
|
||||
cloud.google.com/go/datastore v1.24.0 h1:auNUPJTT9gFcHNj2iKOEeE23nrjf7dE7VA6TO3jw8h0=
|
||||
cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So=
|
||||
cloud.google.com/go/datastore v1.25.0 h1:zUjMnCLCcRZVDSdQIXsbnNCl1SVRNw5Jm0J77gPaPKs=
|
||||
cloud.google.com/go/datastore v1.25.0/go.mod h1:jvJVNe+S2nHVIndV1H/B4s9K3MLsTMqOKlxSrzHTxB4=
|
||||
cloud.google.com/go/datastream v1.20.0 h1:/Xv8hdolIN5SpMgxiiuDc8AncfEuXU9TWRhvQkngCq8=
|
||||
cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ=
|
||||
cloud.google.com/go/datastream v1.21.0 h1:hrWGASBj85xs6wpnx9ifdSBYiRLhKDgQrA1HjeNYBhc=
|
||||
cloud.google.com/go/datastream v1.21.0/go.mod h1:z9AlkQGdXqkeyO5HE+D6sYbOkLJYB4BCZpXFPX/1Vpo=
|
||||
cloud.google.com/go/deploy v1.32.0 h1:vA9yH8EEXOsq1caJpvkJl9wJYA9VU8xuU45V7iC9XHk=
|
||||
cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U=
|
||||
cloud.google.com/go/deploy v1.33.0 h1:2PdZ8kbIztLVRMrIN/wHbalZeZZikQlxBptBFK+eEtM=
|
||||
cloud.google.com/go/deploy v1.33.0/go.mod h1:QdF3plD8D5gV2RmkTXBB6cHrq490WlpFr1SChdOJO2Y=
|
||||
cloud.google.com/go/dialogflow v1.82.0 h1:PKC7h47s036UsW4YxTV2aRCTOChEzMzioczRdlKSApk=
|
||||
cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw=
|
||||
cloud.google.com/go/dialogflow v1.84.0 h1:BymJ6nPotDcnphK2/D0VHMAfakjJlIV5GqxLHQg/Tos=
|
||||
cloud.google.com/go/dialogflow v1.84.0/go.mod h1:OU8Lj1aw5Vr2hl9ifW+vsKnc2b4iJH+41U7nZ4whg3U=
|
||||
cloud.google.com/go/dlp v1.34.0 h1:SI7KSLOtAfhEAw3af8NxpKHGHbJ9BkoM7805TMaU6m4=
|
||||
cloud.google.com/go/dlp v1.34.0/go.mod h1:+haQd/n0QTv5BK7wZnCk2qctd5sfKL50jjh9E6N0d/Q=
|
||||
cloud.google.com/go/dlp v1.36.0 h1:2KOeg+++hpyln8WvbVZGTdgtXxWzpiKQ3re+3dPj5ts=
|
||||
cloud.google.com/go/dlp v1.36.0/go.mod h1:UW92dBhxvqkSKLct+Ril7Y9B4CanS5VLuDwlTGVA9VQ=
|
||||
cloud.google.com/go/documentai v1.48.0 h1:qodgYZJgA89EWNJeeWXWBe/5kq9C5cQhthJweI6Z6CE=
|
||||
cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c=
|
||||
cloud.google.com/go/documentai v1.49.0 h1:WLZOaWvK4NH7xMLxANsDgIdXugeqmVyYRCbVN1m87DQ=
|
||||
cloud.google.com/go/documentai v1.49.0/go.mod h1:VyQA+SxPnCPlVLSJ5UcFx+LQm8JCzK7uUXdkOaAHvG8=
|
||||
cloud.google.com/go/domains v0.15.0 h1:X5RjcYzpsVkuTMZ3OfuSDIv9pBtMlDEk7XXunlfB518=
|
||||
cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk=
|
||||
cloud.google.com/go/domains v0.16.0 h1:h+lcYPxyEulj5SYuH3+OwOsLZb4DTW11QX6nlxxXQ1I=
|
||||
cloud.google.com/go/domains v0.16.0/go.mod h1:O5AhaEyUAgZC2X4M10nSu3dQt2cJLtbjhtrNrdeSPF8=
|
||||
cloud.google.com/go/edgecontainer v1.9.0 h1:9S7YGenFNDVMoh5tulCbSniETQ+XxgjDDie/sEhdkw8=
|
||||
cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE=
|
||||
cloud.google.com/go/edgecontainer v1.10.0 h1:4WOjcIZRCB4ynxH9Zs1tteIcm2LD58VUCLLmIwLYUAc=
|
||||
cloud.google.com/go/edgecontainer v1.10.0/go.mod h1:g4xb11IzVWa9peXNTlnNguKP8uJVvMK4zZeDlGS2Wus=
|
||||
cloud.google.com/go/errorreporting v0.9.0 h1:LlE2SVIbz0k+OSeNTksk34inr3Fy62JMhHUvNaS8f7c=
|
||||
cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84=
|
||||
cloud.google.com/go/essentialcontacts v1.12.0 h1:+AtGn+hYLdr62sMqP0ZGMRzp9A4t+MSWe8eZoD/ho+M=
|
||||
cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A=
|
||||
cloud.google.com/go/eventarc v1.23.0 h1:/EUAdoBWSlqQRbpQYTV2Msmg4esw3Mum3tEU7zkhLi4=
|
||||
cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU=
|
||||
cloud.google.com/go/eventarc v1.25.0 h1:+XAJNEmxJIPZLMtzLYdUZN61Kmqx62hWLry4AsWaTwo=
|
||||
cloud.google.com/go/eventarc v1.25.0/go.mod h1:ncY2NKHKiX+sUjIfxVozrivvmJQ4HWo2znxms7AxlP8=
|
||||
cloud.google.com/go/filestore v1.15.0 h1:ZYFAnP4elMogIQAXFwPx4nKpcvY0dJOZV+zl2l50MGQ=
|
||||
cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM=
|
||||
cloud.google.com/go/filestore v1.16.0 h1:2GXy5jvq2oUX6HnCpx3GjzSqlcMz8K9gkHaF9HPcYNE=
|
||||
cloud.google.com/go/filestore v1.16.0/go.mod h1:szr35omqptDEuXgBbJ8PdVdYM3lf/Md96kNufWr1tVs=
|
||||
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
|
||||
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
|
||||
cloud.google.com/go/firestore v1.24.0 h1:x0Z3hrgjYgo2wI9whuBRQcNc2hYwzZDQy/7pkUXbXcs=
|
||||
cloud.google.com/go/firestore v1.24.0/go.mod h1:5aojyjN4olKUnBZDCRWwM+NsdrrCX3t1qfyERZGOonM=
|
||||
cloud.google.com/go/functions v1.24.0 h1:0nb8LMMABq/oChZg+ovRD5bsc/dNm5ti/aoHRZ9MoUs=
|
||||
cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0=
|
||||
cloud.google.com/go/functions v1.25.0 h1:ndUtLkam3XF9b0t2zVACH9D/EgFBISbVuQFqRz/X58k=
|
||||
cloud.google.com/go/functions v1.25.0/go.mod h1:b/tqakoKeAkj9RspEjqswWf5299Lkz9C/742QUD3OEk=
|
||||
cloud.google.com/go/gkebackup v1.13.0 h1:QyeJc4XPqV0hzoAcAQIi8YMveT8eRI21oOK16qKItJo=
|
||||
cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY=
|
||||
cloud.google.com/go/gkebackup v1.14.0 h1:li3BtGRis1QYrkLo8+Iq2wf5WbP9v3sz9VoUw8WqgaA=
|
||||
cloud.google.com/go/gkebackup v1.14.0/go.mod h1:kaD4l/s0ONcb3L9iHC8PzG1XkC5ggPwA/KAl6yAyQGs=
|
||||
cloud.google.com/go/gkeconnect v1.0.0 h1:IDuAD/w0Xph8k/31vdt6pBkDdRx55lVXz7gW8ABOfCs=
|
||||
cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ=
|
||||
cloud.google.com/go/gkehub v0.21.0 h1:Fvx6c94yYToZBlsYF7tBIt+LW1u6uY6WYK/h3Z6IZYI=
|
||||
cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0=
|
||||
cloud.google.com/go/gkehub v0.22.0 h1:gHKPoWQuWpd9dXLoC58dxwBvSYTNc5/fdYYcs3JUn1s=
|
||||
cloud.google.com/go/gkehub v0.22.0/go.mod h1:WiXX1w9ZHwKZVUDwL//YQfjfWS7yE0I/ym3smZn9iwE=
|
||||
cloud.google.com/go/gkemulticloud v1.11.0 h1:MTqEPjiNVY9bcliSfQR23HHaTPlfFinDh+4ARB5Gn14=
|
||||
cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM=
|
||||
cloud.google.com/go/gkemulticloud v1.12.0 h1:3LPj8ro7bPdZ0BlQJwby25G32rRezGLRdhNM6U3AxGs=
|
||||
cloud.google.com/go/gkemulticloud v1.12.0/go.mod h1:vLNCxGah7pPIoNSX4Yx+hb8klqA0lzzXTWBSut9KzRo=
|
||||
cloud.google.com/go/gsuiteaddons v1.12.0 h1:kz9DyBk84wZCda2Rdha1MOIf7/9x6R+N+LuH9B3zYFs=
|
||||
cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg=
|
||||
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk=
|
||||
cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY=
|
||||
cloud.google.com/go/iap v1.17.0 h1:BDAVJy+juq7cMRumIx9toc4pt1K7zXoZdAI3lDD6D3g=
|
||||
cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0=
|
||||
cloud.google.com/go/ids v1.10.0 h1:uk4kW7UYUtIzlQigKreGKXq4HzbXrspjJ5SzUfPV6qg=
|
||||
cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ=
|
||||
cloud.google.com/go/ids v1.11.0 h1:394LkKIavhv/+oQVMtbXe9WQqCeY9wSFcHktqC9ZkLE=
|
||||
cloud.google.com/go/ids v1.11.0/go.mod h1:+drdvU0pQ4x5uYiWCv364VOeIpTN/PETBrdR51D4Tjk=
|
||||
cloud.google.com/go/iot v1.13.0 h1:pyt1EuMFpbV/2BlmYlXr+HBnUNSPKlP9dVK/dpFTu1U=
|
||||
cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s=
|
||||
cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE=
|
||||
cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
|
||||
cloud.google.com/go/kms v1.32.0 h1:s+rEluaaZKhLVjrIWG7uNBsnWbiitElzNzFGyp6+nIg=
|
||||
cloud.google.com/go/kms v1.32.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA=
|
||||
cloud.google.com/go/language v1.18.0 h1:q58bL7rmxvw6Q6VHt+wjFsAE1Tj/JkuAEIR4+84rx9U=
|
||||
cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04=
|
||||
cloud.google.com/go/lifesciences v0.15.0 h1:sLkI7iAWGPkptWD5f6P9UX6JKiCp5gc4uoa07F8WykI=
|
||||
cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U=
|
||||
cloud.google.com/go/lifesciences v0.16.0 h1:T+7N3oVHK8a7BhX6HJLrBW8xEbMV9iY8Mb+aOl7r6+A=
|
||||
cloud.google.com/go/lifesciences v0.16.0/go.mod h1:axEwGa3A63+vCXIis+0Zkseu8KecqtNoSn7x0zyjJfM=
|
||||
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
|
||||
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
|
||||
cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q=
|
||||
cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA=
|
||||
cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
|
||||
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
|
||||
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
|
||||
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
|
||||
cloud.google.com/go/managedidentities v1.12.0 h1:tGderKWJBrOee9BtGul26gA6425tdbZXkbK0ZSkbAE4=
|
||||
cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA=
|
||||
cloud.google.com/go/managedidentities v1.13.0 h1:ZzWkg3LSrIi9OdzCK3bxutexvw1dDDg9+tOMitbkOXw=
|
||||
cloud.google.com/go/managedidentities v1.13.0/go.mod h1:lUYH5r6QEJTHqjgga0WFeiieqJ0iRwEuQSk20O41Vj0=
|
||||
cloud.google.com/go/maps v1.36.0 h1:MkS6PUuiVmn7YsDv1SEzffTmZ4ucgaiBpZzsz1cE5mk=
|
||||
cloud.google.com/go/maps v1.36.0/go.mod h1:Ly0sd/0G1MgKuWpGc2vCBjNZ+fc8iRHzcBWJqrw7Xao=
|
||||
cloud.google.com/go/maps v1.37.0 h1:YoTRWohpNKeKxdQynorwHH69NZuUM06divZtqGPYjUA=
|
||||
cloud.google.com/go/maps v1.37.0/go.mod h1:oalKFBmf2eHmdr3OvfEiiBlOakNlVitYYEPcM3TTUB4=
|
||||
cloud.google.com/go/mediatranslation v0.13.0 h1:qqMRAK0mhc9M+lM6pb791oh3DYUbwLcIy58jOyXtadE=
|
||||
cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ=
|
||||
cloud.google.com/go/memcache v1.16.0 h1:J6Iq97D6rlDMTJRnTjP3tttRrop8bZDCEKpbsmu0K1c=
|
||||
cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg=
|
||||
cloud.google.com/go/memcache v1.17.0 h1:l/co1jsmVLkNyl8uMqj1u1HIwd3AhCvGdHbEAIPb6jc=
|
||||
cloud.google.com/go/memcache v1.17.0/go.mod h1:QQpFWgJvrFaQ6DgmitHejdbkLg8SJfHg5BzltKEWSt0=
|
||||
cloud.google.com/go/metastore v1.19.0 h1:oAFi3AkO9YZHoDYXo3cbLXlBS4PUUxe5/9kR+q4ta1g=
|
||||
cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs=
|
||||
cloud.google.com/go/metastore v1.20.0 h1:kz0XBkP531MB1IR9jTY6/DB33UFP5Df1vFpPNgW1iPk=
|
||||
cloud.google.com/go/metastore v1.20.0/go.mod h1:/bhZoizjM5iOrqWJeAFDw7c16C783wEftqofnJgKKYI=
|
||||
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
|
||||
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
|
||||
cloud.google.com/go/networkconnectivity v1.26.0 h1:cnPha9p2FFBbxVQA0D5fRBQsROq6tVFmsDZfrGEObtY=
|
||||
cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg=
|
||||
cloud.google.com/go/networkconnectivity v1.27.0 h1:ieYJjbUn2L9ZSzEB9+qKgtoD3/l1/qAnId/Z6KE4ZBo=
|
||||
cloud.google.com/go/networkconnectivity v1.27.0/go.mod h1:pCnczH2W/cnLSlnsnN+VzBoXlM81ZoUGuuacFBGThyw=
|
||||
cloud.google.com/go/networkmanagement v1.28.0 h1:x4U4osf+1qmq7/FRIfjM781mJSeXhmjoDWrbhB4f3Mo=
|
||||
cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU=
|
||||
cloud.google.com/go/networkmanagement v1.30.0 h1:LqYm91vSQk3NM6fHjthseRh4xrDvhXIWQBsQY/Ksus8=
|
||||
cloud.google.com/go/networkmanagement v1.30.0/go.mod h1:3SBf5T7jyGzw5jqJWE7TUDRhIl2E029jggbeoFEgt5E=
|
||||
cloud.google.com/go/networksecurity v0.17.0 h1:6rH2+St9F9n6r49bZ0u7X0Bp7tmWkE0ZguYCWU69xKc=
|
||||
cloud.google.com/go/networksecurity v0.17.0/go.mod h1:NlMistWENBCFt1v748gUn4v9Rk5AVCTDTFs1VSE3JUg=
|
||||
cloud.google.com/go/networksecurity v0.19.0 h1:PzJnbVd0sS2+xDR9hnk31NAyqFt9qB8ohLn7XIsKIBA=
|
||||
cloud.google.com/go/networksecurity v0.19.0/go.mod h1:VWDFX+stDgzZYDsCX1Wy/JO9Tlw7g/V1UHbiORVgqq0=
|
||||
cloud.google.com/go/notebooks v1.17.0 h1:fiezRHPH/H4HatBxbzEQljlmDV8MBv2ffWs1Z6TyHhw=
|
||||
cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM=
|
||||
cloud.google.com/go/notebooks v1.18.0 h1:BgxdQcoRSSjiyszWNOTu3fmXG/6Ks4W2sHgjx0BAke0=
|
||||
cloud.google.com/go/notebooks v1.18.0/go.mod h1:fXU6A3TJ2YobFy6fxOr4tKZZ8QgTjdJAqDIykOB85Gk=
|
||||
cloud.google.com/go/optimization v1.11.0 h1:lh0CcgHOGEAilUn4xS4/gIsSZA4AmTqEhGXdpz6Z+N0=
|
||||
cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8=
|
||||
cloud.google.com/go/optimization v1.12.0 h1:Cd4DgujNhEMcjfaVLGqbjfDwVDnmS1Bc93cx9TE8ZYw=
|
||||
cloud.google.com/go/optimization v1.12.0/go.mod h1:28gzCUmeCLcT4vctGEo71QF4b60TYkKQo5y8Gs2KPq8=
|
||||
cloud.google.com/go/orchestration v1.16.0 h1:aVakYx6wLQV8I8ZDydplEKzQ2+hTJ3Qh/lU5/mwijQA=
|
||||
cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ=
|
||||
cloud.google.com/go/orchestration v1.17.0 h1:dqf2HzqUi4CaaDiNfv6jVoZjz9lF0HWjyIEgccxPHMk=
|
||||
cloud.google.com/go/orchestration v1.17.0/go.mod h1:Lf/Czqh4Jfy3IFpvDkKWjfjkYFI+tj6nAjq5ihivrq4=
|
||||
cloud.google.com/go/orgpolicy v1.20.0 h1:kpVcE/OsC5aAzHCsAiuQSg3+s6ILzgPTuPZyS7n7ejA=
|
||||
cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ=
|
||||
cloud.google.com/go/osconfig v1.21.0 h1:jpq0DNmjS4FkTbNILFdp03uZUQt8D2izpUtgtmSDieQ=
|
||||
cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk=
|
||||
cloud.google.com/go/osconfig v1.22.0 h1:r5lzneR9GNixJ96lZ6oIfJK9Cd5sKpnzZJe1LcGZf4w=
|
||||
cloud.google.com/go/osconfig v1.22.0/go.mod h1:bUL0FaSR2ahPcFRRYnd6a0LyUzsQYIdUpBq8Tmxg8fE=
|
||||
cloud.google.com/go/oslogin v1.18.0 h1:OkccORMcY2XEHwN5+AP0XH/SCUNBl39GGjaQuSCVcIw=
|
||||
cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc=
|
||||
cloud.google.com/go/phishingprotection v0.13.0 h1:6WJF1z3Ie8fZAiCRpeSGB81h6s86XWS2SBC9iQUZxX8=
|
||||
cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k=
|
||||
cloud.google.com/go/policytroubleshooter v1.15.0 h1:nHNbD/2XYM5krsBN9C1W+qSFOlOcbbjct51LXCM1qig=
|
||||
cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4=
|
||||
cloud.google.com/go/policytroubleshooter v1.16.0 h1:V3CcqzY6FhY2c2idRGZv0Ef4mFinLnXnfDFtAhxyTfM=
|
||||
cloud.google.com/go/policytroubleshooter v1.16.0/go.mod h1:FZg3IW3exF6wc9eO/iBYijsGqiiCzc9mjZhsxgATXYA=
|
||||
cloud.google.com/go/privatecatalog v0.15.0 h1:sQSFvJIXKM9RYFK3fPIBDSykC5I2TOmCZMXz9Q3DFsY=
|
||||
cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8=
|
||||
cloud.google.com/go/privatecatalog v0.16.0 h1:SBhcVNVhgEGT1edcfSFY/KSiy2xSQTSQUpY12f10rmw=
|
||||
cloud.google.com/go/privatecatalog v0.16.0/go.mod h1:Dq1bSHRRaDqFr7Rb7UntXVjh1reeY6YdzYicL0EPTrM=
|
||||
cloud.google.com/go/pubsub v1.50.2 h1:54Up97HnThdP4H8jjWJSSQ/mnYG2EKon7ZSNETRq0tM=
|
||||
cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w=
|
||||
cloud.google.com/go/pubsub v1.51.0 h1:XOaCejsqX7EEtUdQz+WPag66wWsUUGliyCOfGPKfo90=
|
||||
cloud.google.com/go/pubsub v1.51.0/go.mod h1:NERXf11sd82UV3VnflcUj8POIyQUXT/QwrKlxD8di/I=
|
||||
cloud.google.com/go/pubsub/v2 v2.5.1 h1:+TwXJr78P9RrMV3S8lKHIhJo2E99jI7ta65e+ujJjts=
|
||||
cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA=
|
||||
cloud.google.com/go/pubsub/v2 v2.6.0 h1:8pjR0id+GTB+krKx5G6AGJoYrHog58w2Q89PCOrfM64=
|
||||
cloud.google.com/go/pubsub/v2 v2.6.0/go.mod h1:4anqvV/w8Pcgu2tO0qr2XgsF3GXHowzryfQ5gOnVmWY=
|
||||
cloud.google.com/go/pubsublite v1.8.2 h1:jLQozsEVr+c6tOU13vDugtnaBSUy/PD5zK6mhm+uF1Y=
|
||||
cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
|
||||
cloud.google.com/go/pubsublite v1.10.0 h1:kqNk9e0gt0/Eu6lChjDlgu4B8k/399FZFpAU4LNq4Co=
|
||||
cloud.google.com/go/pubsublite v1.10.0/go.mod h1:o9NVNBY4m8LubZqRCJtBdxpjP8DAsYizsxC6Z1vI7Dk=
|
||||
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0 h1:9qDHoQtUIZ8FDN5uFAfnNz8jyyXdpAJf6rMN4ZMmMcU=
|
||||
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs=
|
||||
cloud.google.com/go/recommendationengine v0.14.0 h1:kQ+PcZcQBv+FMlZRTp29UYvl3VD5/jsU0MsNgOFTw3I=
|
||||
cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k=
|
||||
cloud.google.com/go/recommendationengine v0.15.0 h1:G0tmQXp67YDaduvde7AX+cIdTtDCo2PBMeqv8SBOpnw=
|
||||
cloud.google.com/go/recommendationengine v0.15.0/go.mod h1:Yx45rCF3A5fLSeXxSkXOCTXSBDBogrQnR7kUTJHwYxw=
|
||||
cloud.google.com/go/recommender v1.19.0 h1:fJ6oO/7Ta/yzfRHcuJUln9iCeo6FDb5yIi2L7eLldzs=
|
||||
cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU=
|
||||
cloud.google.com/go/redis v1.23.0 h1:y/NCxLQR46TQufJNjgINfWsRjCxkgClU37mMf/D1EE4=
|
||||
cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw=
|
||||
cloud.google.com/go/redis v1.24.0 h1:lV6xFUF7t3fm2ZRFjB4IVNkznG9EVwetYVJYwzmW8pc=
|
||||
cloud.google.com/go/redis v1.24.0/go.mod h1:ebtw9WLFKswecHO2ifNykuteNJNwoPqMCHz4UI11kF4=
|
||||
cloud.google.com/go/resourcemanager v1.15.0 h1:OwcTLrKaly0SMPoYHssPG4FBzRF0tyimeySOFD/YPJ0=
|
||||
cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE=
|
||||
cloud.google.com/go/resourcemanager v1.16.0 h1:aDf2RuuQ1Z+kbHHDzHyJNMHPGRFK+cMYfZIhWRaeRX0=
|
||||
cloud.google.com/go/resourcemanager v1.16.0/go.mod h1:Hn4HPkLRnTuiUhFEFJg736Brt7BwlS84xYU06sc3STc=
|
||||
cloud.google.com/go/resourcesettings v1.8.3 h1:13HOFU7v4cEvIHXSAQbinF4wp2Baybbq7q9FMctg1Ek=
|
||||
cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
|
||||
cloud.google.com/go/retail v1.31.0 h1:nJnfVzX+GOIe+PwDNSYG080ydirDzoD53z+c3y7ZzpU=
|
||||
cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk=
|
||||
cloud.google.com/go/retail v1.32.0 h1:u27GLYlnNsQrqzcvSlpZK0L6Ig8k3PEuXGkUdNFCOn4=
|
||||
cloud.google.com/go/retail v1.32.0/go.mod h1:t9w9mBarD59BnFHTST2LoiCP5608ZlEfniHLgA6OoH0=
|
||||
cloud.google.com/go/run v1.21.0 h1:gQJUy0//XNXXpiZs42KlbLPhbycxbpS2QymGRFlPXv4=
|
||||
cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU=
|
||||
cloud.google.com/go/run v1.22.0 h1:U56fxJWdrT+yjo4S/Vrtw5m69NdNL11Cyv9jX2JOi1s=
|
||||
cloud.google.com/go/run v1.22.0/go.mod h1:Wo0aTNrqfftGmbxPPraeOxSUDUZ2c7IVNg2dk8Qm1Bs=
|
||||
cloud.google.com/go/scheduler v1.16.0 h1:EPdChptxnvCasdMixuu58247qhKMO1iAlrVaQhvuRyE=
|
||||
cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA=
|
||||
cloud.google.com/go/secretmanager v1.20.0 h1:GjE3NoyFXo7ipRPy26PMmg4oRX1Ra8fswH45r16rWV0=
|
||||
cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU=
|
||||
cloud.google.com/go/security v1.25.0 h1:U7Op1u7GSbmqEBtQEqxAKG7jMpMFNJ9mTFf+E0HYTJY=
|
||||
cloud.google.com/go/security v1.25.0/go.mod h1:xKPO7XBfUtgjfzPJeznEhI0gp/ZRJt/ZbWtuMYMeUDk=
|
||||
cloud.google.com/go/security v1.26.0 h1:xp/htSPHpyqo225Ju6PjNS9jxfcOEQMxnF229Oze62g=
|
||||
cloud.google.com/go/security v1.26.0/go.mod h1:nd0i5OHXtJduMt0n6UnEojy7fiTfnfj/PSDeD7LAD+c=
|
||||
cloud.google.com/go/securitycenter v1.44.0 h1:/jinB3GeXuNkWfrzK1EdWR+kD4J0z0YGyEe52+gPIoM=
|
||||
cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U=
|
||||
cloud.google.com/go/securitycenter v1.45.0 h1:k5uzLtTSxnh4fwG+8nIcuwDjEE3J+6xe2wcZ72cys7s=
|
||||
cloud.google.com/go/securitycenter v1.45.0/go.mod h1:7mAlzsCsKlEVmciAFORl431laDGpoKGFkSQndAzFs30=
|
||||
cloud.google.com/go/servicedirectory v1.17.0 h1:yrohWkwM8t5JfEFCmmlyksKnpMpvxM8XbRpwg+yIo64=
|
||||
cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk=
|
||||
cloud.google.com/go/shell v1.12.0 h1:eDwvv8ya1BCHCwHCzEIYp/9maLhGCco0LIjeGT4evBA=
|
||||
cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY=
|
||||
cloud.google.com/go/shell v1.13.0 h1:vJ/g4BXCwBRMmUxHoNjdbE0DRh5Aysw/+vrZ/aPl0yc=
|
||||
cloud.google.com/go/shell v1.13.0/go.mod h1:9WWf3xHQUElP5fL/lB9IJ/MMMnN2W/T86cBp+pXFFWo=
|
||||
cloud.google.com/go/spanner v1.91.0 h1:XwXfcZ0kc1NT9Uu2IsThFiWtYptB+WgLn/KZEZcyzRg=
|
||||
cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0=
|
||||
cloud.google.com/go/spanner v1.92.0 h1:cfeMNmtFjz+OYzQVCIuGBw4Cik4CbF2ptXMuRQcUar0=
|
||||
cloud.google.com/go/spanner v1.92.0/go.mod h1:rCDPfWXNX0h+t484r+crCEaaMKbJfoWkHRDKU3H3+oY=
|
||||
cloud.google.com/go/speech v1.35.0 h1:jxWycO5+PfhBWxqnuJNDjNMi85zRK2Jcb4CVhOz6JcA=
|
||||
cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8=
|
||||
cloud.google.com/go/speech v1.36.0 h1:ZcmRKcY02vkF3o/Eaa9yI3i1baNFTF4ctyDZVRfnNS0=
|
||||
cloud.google.com/go/speech v1.36.0/go.mod h1:tiSA8MiX49o1ngq5Ww2JFTvfjKxtAuBKY/UIH6coCPg=
|
||||
cloud.google.com/go/storagetransfer v1.18.0 h1:Y8kA7TiPPjiQH7Xsuf2KlBAJd7Jcn5J8aR5ABO81p/g=
|
||||
cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4=
|
||||
cloud.google.com/go/storagetransfer v1.19.0 h1:jh+3SegMUulEhuJbkADCv8lmfRC8/pCpxja4oex2Ns8=
|
||||
cloud.google.com/go/storagetransfer v1.19.0/go.mod h1:sy4ImXynHkm9CKmbILtmzLN36PHh7JOhUTpqXf5SvMs=
|
||||
cloud.google.com/go/talent v1.13.0 h1:/nZYKG20ZHfZDr7ikRuDnssxk8fuaDxGR+KH3iB4gak=
|
||||
cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y=
|
||||
cloud.google.com/go/talent v1.14.0 h1:dUNSwgBUFlDEMHJpmntEvDeLkPQAoEzBrvDbNfS/UeQ=
|
||||
cloud.google.com/go/talent v1.14.0/go.mod h1:jieYQngp1YqRtqV2t92w3LTrjuLV05kMM4BZMUUneaw=
|
||||
cloud.google.com/go/texttospeech v1.21.0 h1:u1Zvij2JgV3Vci3M2YrotjqnmW4px0uhoVoW8Vv6IP0=
|
||||
cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk=
|
||||
cloud.google.com/go/texttospeech v1.22.0 h1:xGqQv2LB4nttaBNMepCm19EQ0Sk0YdjzPvklDU+LYsI=
|
||||
cloud.google.com/go/texttospeech v1.22.0/go.mod h1:bAksATiWPKaw8r8wVgANa4GkVdsyFE4y9ulRzKyuJec=
|
||||
cloud.google.com/go/tpu v1.13.0 h1:OAtRW+A/+bTLsPS5/trnK7Cz1GceMa2ZlLQzP/ZbSTg=
|
||||
cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug=
|
||||
cloud.google.com/go/tpu v1.14.0 h1:u2f8Lhezy6piu0BB3+4pTLw9xyOeU5n8PCgWjbjCitk=
|
||||
cloud.google.com/go/tpu v1.14.0/go.mod h1:1pggTTG5npfxea6vYjyl60Fg09VgbM7efBgVjnFZjpo=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
cloud.google.com/go/translate v1.17.0 h1:6ecjspRHAOHU+x+e4HOK/2o+bzw7KHwu//eyLVf4TuM=
|
||||
cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU=
|
||||
cloud.google.com/go/translate v1.18.0 h1:2dxWfGMG7y5vobxD2pPyngnfPPIk4MYdNB9QK5sEbuc=
|
||||
cloud.google.com/go/translate v1.18.0/go.mod h1:aRVIE+P+7fngk8HwwFAgis5QA7wphGpKrFpdNoWtGCM=
|
||||
cloud.google.com/go/video v1.32.0 h1:9Us/tkhNRg3WY9wIrVC3Jcs1P0nXKE4XnS1zYJ3xTTY=
|
||||
cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk=
|
||||
cloud.google.com/go/video v1.33.0 h1:zPA41kNscRrJeRo/xD73TjNCFAnRDnP9ViNPuWARVeg=
|
||||
cloud.google.com/go/video v1.33.0/go.mod h1:hEx8TNpQT6kdjMVsywePvT8BCb63Ee3F/R0GRa9wnzo=
|
||||
cloud.google.com/go/videointelligence v1.16.0 h1:WSvC2OI6Su3ulwz0aS7qOVQHO7ZtohUyI6GMqvETY/o=
|
||||
cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4=
|
||||
cloud.google.com/go/videointelligence v1.17.0 h1:cd+s4jLMS59xiYLT6eQ6PVKP8R0PskgifXDa7YwMUUU=
|
||||
cloud.google.com/go/videointelligence v1.17.0/go.mod h1:Phxz7AQpvXoOvz+KrrOZEJRo4CDgYXMDVDqhCtdF1jc=
|
||||
cloud.google.com/go/vision/v2 v2.14.0 h1:l4CjEOm9veghGSutx79p+WG6vI6/5DPjRsAasmi9zX4=
|
||||
cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ=
|
||||
cloud.google.com/go/vision/v2 v2.15.0 h1:aTR1vj4++WtS9HD6YdGuoaYygMTJ873WaoV9sYjlQCc=
|
||||
cloud.google.com/go/vision/v2 v2.15.0/go.mod h1:DUdjdFkXqPvEoPC4WDYFvYCn0LlAZ4vVz29A0bXvW90=
|
||||
cloud.google.com/go/vmmigration v1.15.0 h1:F2uqT8+JXvSywV381YoQ4To3RnJETRtPkvcbdWXGmgM=
|
||||
cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY=
|
||||
cloud.google.com/go/vmmigration v1.16.0 h1:OPGMxx73owRHMve5B4L6W+0KQUwlunyE9kfbYzFHqug=
|
||||
cloud.google.com/go/vmmigration v1.16.0/go.mod h1:ILrSjXnHMpdamkkAU8fjMKKMsH27B6FLC5kv/6TkLy0=
|
||||
cloud.google.com/go/vmwareengine v1.8.0 h1:TmHKgTRH+mjq2VaaxrNcXqWyeleX7YaJPvrfWFCn0eE=
|
||||
cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0=
|
||||
cloud.google.com/go/vmwareengine v1.9.0 h1:vdSutihjzH5iYEFlN+1x+gDm4xzj6Md2yMzXADKI5cQ=
|
||||
cloud.google.com/go/vmwareengine v1.9.0/go.mod h1:zXXuUaIpvDhsV6sR+JdQfcQ4V5+pDarrp7FW7nOdS2I=
|
||||
cloud.google.com/go/vpcaccess v1.13.0 h1:aU7IKE/IAUgOzXCOgPsku4nV2DwmRpJHn6+QMf5Ub70=
|
||||
cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8=
|
||||
cloud.google.com/go/vpcaccess v1.14.0 h1:fFa1i67AjC/3MuG5GPlvx36sIMeffpsIs3kI1C7S3Jk=
|
||||
cloud.google.com/go/vpcaccess v1.14.0/go.mod h1:MxbVgr+2fpIFIEIdSmgnb8ykNWRPVtslpmWijp7an68=
|
||||
cloud.google.com/go/webrisk v1.16.0 h1:OKkOJ81+YjGnrfN3oBNdpycZqKFNE4w52fSGo32rgNw=
|
||||
cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc=
|
||||
cloud.google.com/go/webrisk v1.17.0 h1:t2WMlBo3xXxH6k5kTSkyIYUl24AHgmBI76F/5OqAPRY=
|
||||
cloud.google.com/go/webrisk v1.17.0/go.mod h1:ypwCZ+G/SXyUZ+x3ppxn1hu+6tDifGNd/OpwPtCdJHI=
|
||||
cloud.google.com/go/websecurityscanner v1.12.0 h1:iV+hAXeEo8kKtFWDZWPp9Z0fsziZnuOt4nCCYp68RP0=
|
||||
cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8=
|
||||
cloud.google.com/go/workflows v1.19.0 h1:O5LlH7x1QovbDssany0TBe+hcSOcK5gPgIeaoByy0ZU=
|
||||
cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g=
|
||||
cloud.google.com/go/workflows v1.20.0 h1:qROpn1zRDdeIjUP9w9O3MHXJEg8v/pzVMicAcw9vLvQ=
|
||||
cloud.google.com/go/workflows v1.20.0/go.mod h1:TC9yx7VpjGdBBeKM8FG2EMtms5Q9nyTqI+2uV9bDNs4=
|
||||
codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY=
|
||||
codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ=
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
|
||||
@@ -276,6 +454,8 @@ github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao
|
||||
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||
github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
@@ -305,6 +485,7 @@ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqr
|
||||
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
|
||||
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM=
|
||||
@@ -553,6 +734,8 @@ github.com/kataras/iris/v12 v12.1.8 h1:O3gJasjm7ZxpxwTH8tApZsvf274scSGQAUpNe47c3
|
||||
github.com/kataras/neffos v0.0.14 h1:pdJaTvUG3NQfeMbbVCI8JT2T5goPldyyfUB2PJfh1Bs=
|
||||
github.com/kataras/pio v0.0.2 h1:6NAi+uPJ/Zuid6mrAKlgpbI11/zK/lV4B2rxWaJN98Y=
|
||||
github.com/kataras/sitemap v0.0.5 h1:4HCONX5RLgVy6G4RkYOV3vKNcma9p236LdGOipJsaFE=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
|
||||
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
|
||||
@@ -659,6 +842,7 @@ github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtz
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rollbar/rollbar-go v1.0.2 h1:uA3+z0jq6ka9WUUt9VX/xuiQZXZyWRoeKvkhVvLO9Jc=
|
||||
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
|
||||
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
|
||||
@@ -785,6 +969,8 @@ go.etcd.io/gofail v0.1.0 h1:XItAMIhOojXFQMgrxjnd2EIIHun/d5qL0Pf7FzVTkFg=
|
||||
go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
@@ -815,6 +1001,8 @@ golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
@@ -826,7 +1014,6 @@ golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
@@ -869,10 +1056,10 @@ golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
|
||||
google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29 h1:DJUvgAPiJWeMBiT+RzBVcJGQN7bAEWS5UEoMshES9xs=
|
||||
google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
@@ -891,6 +1078,24 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
|
||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
|
||||
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
|
||||
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
|
||||
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI=
|
||||
|
||||
@@ -739,8 +739,11 @@ export async function listTenants(): Promise<TenantRow[]> {
|
||||
return ((await res.json()) as { tenants?: TenantRow[] }).tenants ?? [];
|
||||
}
|
||||
|
||||
export async function createTenant(name: string, slug: string, ownerEmail?: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "" }) }));
|
||||
export async function createTenant(name: string, slug: string, ownerEmail?: string, plan?: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants`, {
|
||||
method: "POST", headers: authHeaders(true),
|
||||
body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "", plan: plan ?? "free" }),
|
||||
}));
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string; warn?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `create tenant failed: ${res.status}`);
|
||||
if (d.warn) throw new Error(d.warn); // 租户已建但指定 owner 失败 → 当提示
|
||||
@@ -783,3 +786,33 @@ export async function setSharedBilling(tenantId: string, on: boolean): Promise<v
|
||||
throw new Error(d.error ?? `set shared billing failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// —— KB 存储迁移(管理员一次性运维操作:owner/kb 作用域 → space_id/kb 作用域)——
|
||||
export interface MigrateKbResult {
|
||||
total: number; // 扫描文档总数
|
||||
enqueued: number; // 成功入队重灌的文档数
|
||||
skipped: number; // 已是新作用域跳过数
|
||||
}
|
||||
export async function migrateKbStorage(): Promise<MigrateKbResult> {
|
||||
const res = guard(await fetch(`${ADMIN}/migrate-kb-storage`, { method: "POST", headers: authHeaders(true) }));
|
||||
const d = (await res.json().catch(() => ({}))) as Partial<MigrateKbResult> & { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `migrate kb failed: ${res.status}`);
|
||||
return { total: d.total ?? 0, enqueued: d.enqueued ?? 0, skipped: d.skipped ?? 0 };
|
||||
}
|
||||
|
||||
export async function setTenantPlan(tenantId: string, plan: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/plan`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ plan }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `set tenant plan failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setTenantStatus(tenantId: string, status: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/status`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ status }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `set tenant status failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { grantCredits, type TenantRow } from "../api";
|
||||
|
||||
// GrantCreditsModal:替换 window.prompt 的完整充值弹窗。
|
||||
// 支持正数充值 / 负数人工校正;带操作原因 + 备注 + 风险提示 + 汇率说明。
|
||||
|
||||
const MICRO = 1_000_000;
|
||||
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const REASONS = [
|
||||
"线下回款核销",
|
||||
"补偿发放(服务故障)",
|
||||
"测试赠送",
|
||||
"人工校正(超扣补偿)",
|
||||
"合同预付款",
|
||||
"其它(见备注)",
|
||||
];
|
||||
|
||||
interface Props {
|
||||
tenant: TenantRow | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function GrantCreditsModal({ tenant, onClose, onDone }: Props) {
|
||||
const [amount, setAmount] = useState("100");
|
||||
const [reason, setReason] = useState(REASONS[0]);
|
||||
const [memo, setMemo] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const amountRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 每次打开聚焦数量输入框
|
||||
useEffect(() => {
|
||||
if (tenant) {
|
||||
setAmount("100");
|
||||
setReason(REASONS[0]);
|
||||
setMemo("");
|
||||
setErr("");
|
||||
setTimeout(() => amountRef.current?.select(), 50);
|
||||
}
|
||||
}, [tenant]);
|
||||
|
||||
if (!tenant) return null;
|
||||
|
||||
const n = Number(amount);
|
||||
const isValid = !isNaN(n) && n !== 0;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!isValid) {
|
||||
setErr("积分数量不能为 0");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setErr("");
|
||||
try {
|
||||
const finalMemo = reason === "其它(见备注)" ? memo : reason + (memo ? `:${memo}` : "");
|
||||
await grantCredits(tenant.id, n, finalMemo);
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">手动发放积分</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">操作将写入账本分录,可在对账页追溯</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* 租户信息 */}
|
||||
<div className="flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50 p-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-violet-100 text-sm font-bold text-violet-700">
|
||||
{tenant.name.charAt(0)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-800">{tenant.name}</div>
|
||||
<div className="font-mono text-xs text-gray-400">
|
||||
当前余额:<span className="font-medium text-violet-600">{credits(tenant.credit_balance_micro)}</span> 积分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 积分数量 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">积分数量</label>
|
||||
<input
|
||||
ref={amountRef}
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => { setAmount(e.target.value); setErr(""); }}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm font-mono focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
placeholder="正数=充值,负数=扣减/校正"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">正数=充值/发放,负数=扣减/校正。当前汇率:1 积分 = 1000 token</p>
|
||||
</div>
|
||||
|
||||
{/* 操作原因 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">操作原因</label>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-700 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
>
|
||||
{REASONS.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">
|
||||
备注(可选){reason === "其它(见备注)" && <span className="text-rose-500"> *必填</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="如:2026-07-18 线下转账 ¥500 核销"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 风险提示 */}
|
||||
<div className="rounded-lg border border-amber-100 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
⚠️ 此操作会立即更新租户余额并写入账本,无法撤销。如需扣减请填写负数。
|
||||
</div>
|
||||
|
||||
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<span className="text-xs text-gray-400">操作完成后自动记入审计日志</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={saving || !isValid}
|
||||
className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-40"
|
||||
>
|
||||
{saving ? "发放中…" : "确认发放"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { adminOrders, adminReconcile, adminRefundOrder, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
|
||||
|
||||
|
||||
// 充值订单流 + 日终对账(P5.3 观测)。全平台充值单的落地视角:
|
||||
// - 状态计数卡片(pending/paid/expired + 累计到账额)
|
||||
// - 订单流(可按状态筛)
|
||||
@@ -28,6 +29,12 @@ export function OrderStream() {
|
||||
const [diffs, setDiffs] = useState<ReconcileDiff[] | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [refunding, setRefunding] = useState(""); // 正在退款的订单 id
|
||||
// 退款 Modal
|
||||
const [refundTarget, setRefundTarget] = useState<PayOrder | null>(null);
|
||||
const [refundMemo, setRefundMemo] = useState("");
|
||||
const [refundErr, setRefundErr] = useState("");
|
||||
const refundMemoRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
||||
const load = useCallback(() => {
|
||||
adminOrders(filter)
|
||||
@@ -52,29 +59,94 @@ export function OrderStream() {
|
||||
}
|
||||
};
|
||||
|
||||
const refund = async (o: PayOrder) => {
|
||||
// 退款不可逆(冲销积分、订单置 refunded),且真渠道钱需另在商户后台原路退——二次确认 + 留原因。
|
||||
const memo = window.prompt(
|
||||
`确认退款?将冲销 ${credits(o.credits_micro)} 积分并把订单置为已退款(余额可能因积分已消费而变负)。\n` +
|
||||
`注意:微信真单的钱款原路退回需另在商户后台操作,此处仅冲销本地积分与订单态。\n\n请填写退款原因:`,
|
||||
"",
|
||||
);
|
||||
if (memo === null) return; // 取消
|
||||
setRefunding(o.id);
|
||||
setErr("");
|
||||
const openRefund = (o: PayOrder) => {
|
||||
setRefundTarget(o);
|
||||
setRefundMemo("");
|
||||
setRefundErr("");
|
||||
setTimeout(() => refundMemoRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const doRefund = async () => {
|
||||
if (!refundTarget) return;
|
||||
if (!refundMemo.trim()) { setRefundErr("退款原因不能为空"); return; }
|
||||
setRefunding(refundTarget.id);
|
||||
setRefundErr("");
|
||||
try {
|
||||
const r = await adminRefundOrder(o.id, memo);
|
||||
const r = await adminRefundOrder(refundTarget.id, refundMemo.trim());
|
||||
if (r.status === "noop") setErr(r.detail ?? "该订单无需退款");
|
||||
setRefundTarget(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setRefundErr((e as Error).message);
|
||||
} finally {
|
||||
setRefunding("");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 退款 Modal */}
|
||||
{refundTarget && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && setRefundTarget(null)}
|
||||
>
|
||||
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">人工退款</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">冲销订单积分并调整余额</p>
|
||||
</div>
|
||||
<button onClick={() => setRefundTarget(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* 订单摘要 */}
|
||||
<div className="space-y-2 rounded-xl border border-gray-100 bg-gray-50 p-3 text-sm">
|
||||
<div className="flex justify-between text-xs uppercase tracking-wide text-gray-400">
|
||||
<span>订单信息</span>
|
||||
<span className="font-mono">{refundTarget.id.slice(-12)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between"><span className="text-gray-600">租户</span><span className="font-medium">{refundTarget.tenant_name || refundTarget.tenant_id}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-600">充値积分</span><span className="font-medium text-gray-800">{credits(refundTarget.credits_micro)} 积分</span></div>
|
||||
{refundTarget.amount_fen > 0 && <div className="flex justify-between"><span className="text-gray-600">充値金额</span><span className="font-medium text-emerald-600">{yuan(refundTarget.amount_fen)}</span></div>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">退款原因(必填)</label>
|
||||
<textarea
|
||||
ref={refundMemoRef}
|
||||
rows={3}
|
||||
value={refundMemo}
|
||||
onChange={(e) => { setRefundMemo(e.target.value); setRefundErr(""); }}
|
||||
placeholder="请说明退款原因,此内容将写入审计日志…"
|
||||
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs text-rose-700 space-y-1">
|
||||
<div className="font-semibold">⚠️ 操作风险提示</div>
|
||||
<div>· 本操作将冲销 <strong>{credits(refundTarget.credits_micro)} 积分</strong>,若已消费则余额可能变负</div>
|
||||
<div>· 微信真实资金的原路退回需另在商户后台操作,此处仅冲销本地积分</div>
|
||||
<div>· 操作不可撤销</div>
|
||||
</div>
|
||||
{refundErr && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{refundErr}</div>}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<span className="text-xs text-gray-400">操作将记入审计日志</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setRefundTarget(null)} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50">取消</button>
|
||||
<button
|
||||
onClick={() => void doRefund()}
|
||||
disabled={!!refunding || !refundMemo.trim()}
|
||||
className="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-40"
|
||||
>
|
||||
{refunding ? "退款中…" : "确认退款"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<h3 className="text-sm font-semibold text-gray-700">充值订单与对账</h3>
|
||||
<span className="text-[11px] text-gray-400">全平台充值单落地视角 + 账本一致性核对</span>
|
||||
@@ -158,7 +230,7 @@ export function OrderStream() {
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{o.status === "paid" ? (
|
||||
<button onClick={() => void refund(o)} disabled={refunding === o.id}
|
||||
<button onClick={() => openRefund(o)} disabled={refunding === o.id}
|
||||
className="rounded border border-rose-200 px-2 py-0.5 text-[11px] text-rose-600 hover:bg-rose-50 disabled:opacity-40">
|
||||
{refunding === o.id ? "退款中…" : "退款"}
|
||||
</button>
|
||||
|
||||
@@ -1,33 +1,60 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listAudit, listGuardrailEvents, type AuditEntry, type GuardrailEventItem } from "../api";
|
||||
import { listAudit, type AuditEntry } from "../api";
|
||||
|
||||
// 操作方法配色。
|
||||
// 操作方法配色
|
||||
const ACTION_STYLE: Record<string, string> = {
|
||||
POST: "bg-emerald-50 text-emerald-600",
|
||||
PUT: "bg-amber-50 text-amber-600",
|
||||
DELETE: "bg-rose-50 text-rose-600",
|
||||
PATCH: "bg-violet-50 text-violet-600",
|
||||
POST: "bg-emerald-50 text-emerald-600 border border-emerald-100",
|
||||
PUT: "bg-amber-50 text-amber-600 border border-amber-100",
|
||||
DELETE: "bg-rose-50 text-rose-600 border border-rose-100",
|
||||
PATCH: "bg-violet-50 text-violet-600 border border-violet-100",
|
||||
GET: "bg-gray-50 text-gray-600 border border-gray-100",
|
||||
};
|
||||
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
|
||||
|
||||
// uid 太长,展示时截断(保留首尾,hover 看全)。
|
||||
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
|
||||
const shortId = (id: string) => (id && id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id || "系统");
|
||||
const fmt = (at: string) => new Date(at).toLocaleString("zh-CN", { hour12: false });
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
const ROUTE_OPTS = [
|
||||
{ value: "", label: "所有路由" },
|
||||
{ value: "/api/v1/admin", label: "管理员接口 (/admin)" },
|
||||
{ value: "/api/v1/prompts", label: "提示词接口 (/prompts)" },
|
||||
{ value: "/api/v1/auth", label: "鉴权接口 (/auth)" },
|
||||
{ value: "/api/v1/spaces", label: "空间接口 (/spaces)" },
|
||||
];
|
||||
|
||||
export function AuditPage() {
|
||||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||
const [events, setEvents] = useState<GuardrailEventItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
// 筛选器状态
|
||||
const [methodFilter, setMethodFilter] = useState<string>(""); // 空 = 全部
|
||||
const [routeFilter, setRouteFilter] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const load = async (pageNum = page, showRef = false) => {
|
||||
if (showRef) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
|
||||
try {
|
||||
const [a, e] = await Promise.all([listAudit(80), listGuardrailEvents(80)]);
|
||||
setAudit(a);
|
||||
setEvents(e);
|
||||
const offset = (pageNum - 1) * LIMIT;
|
||||
// 从后端载入比 LIMIT 稍微多一条,以此判断是否有下一页
|
||||
const data = await listAudit(LIMIT + 1, offset);
|
||||
if (data.length > LIMIT) {
|
||||
setAudit(data.slice(0, LIMIT));
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setAudit(data);
|
||||
setHasMore(false);
|
||||
}
|
||||
setUpdatedAt(new Date());
|
||||
setErr("");
|
||||
} catch (er) {
|
||||
@@ -39,36 +66,47 @@ export function AuditPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const t = setInterval(() => void load(), 30000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
void load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载审计记录中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">加载失败:{err}</div>;
|
||||
// 前端过滤(配合后端分页后的过滤,或按需做简单的前端实时匹配)
|
||||
const filteredAudit = audit.filter((a) => {
|
||||
if (methodFilter && a.action !== methodFilter) return false;
|
||||
if (routeFilter && !a.path.startsWith(routeFilter)) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchActor = a.actor.toLowerCase().includes(q);
|
||||
const matchIp = a.ip.toLowerCase().includes(q);
|
||||
const matchDetail = a.detail?.toLowerCase().includes(q) ?? false;
|
||||
const matchPath = a.path.toLowerCase().includes(q);
|
||||
if (!matchActor && !matchIp && !matchDetail && !matchPath) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const blocked = events.filter((e) => e.kind === "blocked").length;
|
||||
const suspect = events.filter((e) => e.kind === "suspect").length;
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) setPage(page - 1);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasMore) setPage(page + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 顶栏 */}
|
||||
<div className="flex flex-wrap items-center gap-4 rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mr-auto">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">审计 & 安全事件</h3>
|
||||
<p className="text-xs text-gray-400">敏感操作留痕 + 输入护栏命中 · 只增不改,供运维溯源</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-gray-150 bg-white p-5 shadow-sm">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">敏感操作审计</h3>
|
||||
<p className="text-xs text-gray-400">敏感操作全链路留痕 · 只增不改,供合规审计与故障溯源</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-100">
|
||||
<Stat value={audit.length} label="操作留痕" />
|
||||
<Stat value={blocked} label="护栏拦截" bad={blocked > 0} />
|
||||
<Stat value={suspect} label="灰区放行" warn={suspect > 0} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
{updatedAt && <span>更新于 {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
{updatedAt && <span>最近更新:{updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
onClick={() => void load(page, true)}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
@@ -78,54 +116,134 @@ export function AuditPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 安全事件(护栏命中) */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">安全事件</h3>
|
||||
<span className="text-xs text-gray-400">输入护栏拦截 / 灰区放行({events.length})</span>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400">暂无护栏命中 🎉</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-3 py-2.5 text-xs">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${e.kind === "blocked" ? "bg-rose-50 text-rose-600" : "bg-amber-50 text-amber-600"}`}>
|
||||
{e.kind === "blocked" ? "拦截" : "灰区"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-gray-700" title={e.reason}>{e.reason || "(无原因)"}</span>
|
||||
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:inline">{e.method} {e.path}</code>
|
||||
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={e.actor}>{shortId(e.actor)}</span>
|
||||
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{e.ip}</span>
|
||||
<span className="shrink-0 text-[10px] text-gray-300">{fmt(e.at)}</span>
|
||||
</div>
|
||||
{/* 筛选工具栏 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{/* HTTP Method Button Group */}
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-gray-150 bg-gray-50 p-1 text-xs">
|
||||
{["", "GET", "POST", "PUT", "DELETE"].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => { setMethodFilter(m); setPage(1); }}
|
||||
className={`rounded px-3 py-1 font-semibold transition-all ${
|
||||
methodFilter === m
|
||||
? "bg-violet-600 text-white shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{m || "全部"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 操作审计 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">操作审计</h3>
|
||||
<span className="text-xs text-gray-400">改配置 / 改密钥 / 激活提示词 / 审批 等变更操作({audit.length})</span>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Route prefix select */}
|
||||
<select
|
||||
value={routeFilter}
|
||||
onChange={(e) => { setRouteFilter(e.target.value); setPage(1); }}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-700 focus:outline-none focus:border-violet-400 shadow-sm"
|
||||
>
|
||||
{ROUTE_OPTS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索操作者 ID / IP / 详情…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-56 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{audit.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400">暂无操作留痕</div>
|
||||
</div>
|
||||
|
||||
{/* 审计日志列表 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-baseline justify-between border-b pb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">操作日志明细</h3>
|
||||
<span className="text-xs text-gray-400">当前页展示 {filteredAudit.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-xs text-gray-400 animate-pulse">正在载入审计记录…</div>
|
||||
) : err ? (
|
||||
<div className="py-12 text-center text-xs text-rose-500">加载失败:{err}</div>
|
||||
) : filteredAudit.length === 0 ? (
|
||||
<div className="py-12 text-center text-xs text-gray-400">没有匹配的审计记录</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{audit.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-3 py-2.5 text-xs">
|
||||
<span className={`w-14 shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-semibold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{a.action}
|
||||
</span>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-600" title={a.path}>{a.path}</code>
|
||||
<span className={`shrink-0 font-mono text-[10px] ${statusTone(a.status)}`}>{a.status}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={a.actor}>{shortId(a.actor)}</span>
|
||||
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{a.ip}</span>
|
||||
<span className="shrink-0 text-[10px] text-gray-300">{fmt(a.at)}</span>
|
||||
<div className="space-y-1.5">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium w-16">方法</th>
|
||||
<th className="py-2 pr-3 font-medium">路由路径</th>
|
||||
<th className="py-2 pr-3 font-medium w-16">状态</th>
|
||||
<th className="py-2 pr-3 font-medium">详情</th>
|
||||
<th className="py-2 pr-3 font-medium w-24">操作人</th>
|
||||
<th className="py-2 pr-3 font-medium w-28">客户端 IP</th>
|
||||
<th className="py-2 font-medium w-36">触发时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAudit.map((a) => (
|
||||
<tr key={a.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
|
||||
<td className="py-2.5 pr-3">
|
||||
<span className={`inline-block w-14 rounded px-1.5 py-0.5 text-center text-[10px] font-bold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{a.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 max-w-[12rem] truncate font-mono text-[11px] text-gray-600" title={a.path}>
|
||||
{a.path}
|
||||
</td>
|
||||
<td className={`py-2.5 pr-3 font-mono font-semibold ${statusTone(a.status)}`}>
|
||||
{a.status}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-500 text-[11px] max-w-[20rem] truncate" title={a.detail}>
|
||||
{a.detail || "—"}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 font-mono text-gray-400" title={a.actor}>
|
||||
{shortId(a.actor)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-400 font-mono">
|
||||
{a.ip}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-400">
|
||||
{fmt(a.at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
当前第 <span className="font-semibold text-gray-700">{page}</span> 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -133,11 +251,3 @@ export function AuditPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label, bad, warn }: { value: number; label: string; bad?: boolean; warn?: boolean }) {
|
||||
return (
|
||||
<div className="px-4 first:pl-0">
|
||||
<div className={`text-xl font-semibold tracking-tight ${bad ? "text-rose-500" : warn ? "text-amber-600" : "text-gray-900"}`}>{value}</div>
|
||||
<div className="text-[10px] text-gray-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ModelManager } from "../components/ModelManager";
|
||||
import { adminDatasources, type DatasourceKB } from "../api";
|
||||
import { adminDatasources, listModels, migrateKbStorage, type DatasourceKB, type MigrateKbResult } from "../api";
|
||||
|
||||
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
|
||||
// 此前该页 GraphRAG 拓扑图与「向量/全文/图谱权重滑块」全 mock——而且权重概念本身是虚构的:
|
||||
// mcp-go 的 RRF 融合是各路等权的倒排互惠融合(rrfK=60 平滑常数),没有"每路占几成"的权重。
|
||||
// 故删假滑块+假拓扑,换成真数据源清单 + 诚实的检索管线说明。
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { general: "通用", folder: "文件夹", project: "项目", case: "案例" };
|
||||
const fmtWords = (n: number) => (n >= 1e4 ? `${(n / 1e4).toFixed(1)}万` : `${n}`);
|
||||
|
||||
@@ -15,6 +10,17 @@ export function DatasourcesPage() {
|
||||
const [rows, setRows] = useState<DatasourceKB[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// 运行时的 active embedding 模型展示
|
||||
const [activeEmbedding, setActiveEmbedding] = useState<string>("加载中…");
|
||||
|
||||
// 运维工具折叠状态
|
||||
const [opsOpen, setOpsOpen] = useState(false);
|
||||
|
||||
// 迁移 Modal 状态
|
||||
const [migrationModal, setMigrationModal] = useState<"none" | "confirm" | "running" | "done">("none");
|
||||
const [migrationResult, setMigrationResult] = useState<MigrateKbResult | null>(null);
|
||||
const [migrationErr, setMigrationErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
adminDatasources()
|
||||
@@ -25,19 +31,203 @@ export function DatasourcesPage() {
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
// 获取激活的 embedding 模型
|
||||
listModels("embedding")
|
||||
.then((models) => {
|
||||
const active = models.find((m) => m.active);
|
||||
if (active) {
|
||||
setActiveEmbedding(`${active.model} (${active.provider})`);
|
||||
} else {
|
||||
setActiveEmbedding("未配置/未激活 (将回退代码默认)");
|
||||
}
|
||||
})
|
||||
.catch(() => setActiveEmbedding("获取失败"));
|
||||
}, []);
|
||||
|
||||
const startMigration = async () => {
|
||||
setMigrationModal("running");
|
||||
setMigrationErr("");
|
||||
try {
|
||||
const res = await migrateKbStorage();
|
||||
setMigrationResult(res);
|
||||
setMigrationModal("done");
|
||||
} catch (e) {
|
||||
setMigrationErr((e as Error).message);
|
||||
setMigrationModal("confirm"); // 退回到确认态展示错误
|
||||
}
|
||||
};
|
||||
|
||||
const totalWords = rows.reduce((a, r) => a + r.total_words, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Embedding 模型配置(真实组件) */}
|
||||
<ModelManager
|
||||
kind="embedding"
|
||||
title="Embedding 模型(embedding → mcp-go RAG)"
|
||||
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
modelHint="text-embedding-v3"
|
||||
/>
|
||||
{/* 顶部激活模型只读展示 & 引导 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">当前激活向量化模型 (Embedding)</h4>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
<code className="text-xs font-mono font-medium text-gray-800">{activeEmbedding}</code>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="#/models"
|
||||
onClick={(e) => {
|
||||
// 如果使用 hash 路由或 react-router,可正常导航。这里提示用户去模型页配置
|
||||
window.location.hash = "#/models";
|
||||
}}
|
||||
className="text-xs text-violet-600 hover:text-violet-700 font-medium hover:underline flex items-center gap-1"
|
||||
>
|
||||
前往模型管理修改 →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 运维工具 (RAG 迁移) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<button
|
||||
onClick={() => setOpsOpen(!opsOpen)}
|
||||
className="flex w-full items-center justify-between font-semibold text-gray-700 focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-gray-700">运维工具</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{opsOpen ? "收起 ▲" : "展开 ▼"}</span>
|
||||
</button>
|
||||
|
||||
{opsOpen && (
|
||||
<div className="mt-4 border-t border-gray-100 pt-4 space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 text-xs bg-amber-50/50 border border-amber-100 p-4 rounded-lg">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-amber-800 flex items-center gap-1.5">
|
||||
<span>知识库存储目录路径迁移</span>
|
||||
<span className="bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded text-[9px] font-bold">建议在低峰期操作</span>
|
||||
</div>
|
||||
<p className="text-gray-500 leading-relaxed max-w-xl">
|
||||
将平台全量知识库底层的存储目录规范,由早期版本的 <code className="bg-gray-100 px-1 rounded">owner/kb_id/doc</code> 升级为新版本统一的 <code className="bg-gray-100 px-1 rounded">space_id/kb_id/doc</code> 规范。此操作将遍历所有文件,并在数据库与对象存储中更新映射关系,迁移期间可能产生轻微检索延迟。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMigrationModal("confirm")}
|
||||
className="self-start md:self-center shrink-0 bg-amber-600 hover:bg-amber-700 text-white text-xs font-semibold py-2 px-4 rounded-lg shadow-sm transition"
|
||||
>
|
||||
开始迁移
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 迁移 Modal */}
|
||||
{migrationModal !== "none" && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
{migrationModal === "confirm" && "确认开始存储迁移?"}
|
||||
{migrationModal === "running" && "正在迁移中…"}
|
||||
{migrationModal === "done" && "迁移完成 🎉"}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">一键升级平台 KB 作用域至 space_id</p>
|
||||
</div>
|
||||
{migrationModal !== "running" && (
|
||||
<button
|
||||
onClick={() => setMigrationModal("none")}
|
||||
className="text-xl leading-none text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-5 text-sm">
|
||||
{migrationModal === "confirm" && (
|
||||
<>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
您将触发全平台的数据目录热迁移。这会将旧版未挂载空间的独立知识库映射,统一调整至 Space 空间映射下。
|
||||
</p>
|
||||
<div className="rounded-lg border border-amber-100 bg-amber-50 p-3 text-xs text-amber-700 space-y-1">
|
||||
<div className="font-semibold">⚠️ 运维安全警示:</div>
|
||||
<div>· 这是一个高危数据库与存储热变更操作</div>
|
||||
<div>· 建议执行前对 <code className="font-mono bg-amber-100/50 px-1">sundynix_doc</code> 表进行备份</div>
|
||||
<div>· 确认执行后不可中途停止</div>
|
||||
</div>
|
||||
{migrationErr && (
|
||||
<div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">
|
||||
操作失败:{migrationErr}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{migrationModal === "running" && (
|
||||
<div className="flex flex-col items-center justify-center py-6 space-y-3">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-xs text-gray-500 font-medium animate-pulse">正在扫描文档并重新入队,请勿关闭窗口…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{migrationModal === "done" && migrationResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
|
||||
数据升级指令已成功执行,各文档已重新入队执行 RRF 索引重灌。
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 border rounded-xl overflow-hidden bg-gray-50/50">
|
||||
<div className="flex justify-between p-3 text-xs">
|
||||
<span className="text-gray-500">已扫描文档总数</span>
|
||||
<span className="font-semibold text-gray-800 font-mono">{migrationResult.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-3 text-xs">
|
||||
<span className="text-gray-500">入队重灌文档数</span>
|
||||
<span className="font-semibold text-amber-600 font-mono">{migrationResult.enqueued}</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-3 text-xs">
|
||||
<span className="text-gray-500">跳过(已是新作用域)</span>
|
||||
<span className="font-semibold text-gray-600 font-mono">{migrationResult.skipped}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-4">
|
||||
{migrationModal === "confirm" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setMigrationModal("none")}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-xs text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void startMigration()}
|
||||
className="rounded-lg bg-amber-600 hover:bg-amber-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
|
||||
>
|
||||
确认迁移
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{migrationModal === "done" && (
|
||||
<button
|
||||
onClick={() => setMigrationModal("none")}
|
||||
className="rounded-lg bg-violet-600 hover:bg-violet-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 检索管线说明(诚实:三路 + RRF 等权融合,非可调权重) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { guardrailEvents, type GuardrailEvent } from "../api";
|
||||
import { listGuardrailEvents, type GuardrailEventItem } from "../api";
|
||||
|
||||
// 输入护栏观测:真数据(来自 guardrail_event,middleware.Guardrail 命中即落库)。
|
||||
// 命中事件流(blocked 硬拦 / suspect 灰区放行→Tier2 LLM 裁决) + 计数 + 原因分布。
|
||||
//
|
||||
// 诚实边界:护栏规则(Tier1 正则/敏感词 + Tier2 LLM 分类器)目前是中间件里的代码常量,
|
||||
// 尚不支持运行时编辑(改规则需改代码重部署)。故本页只做观测,不摆"能改却不生效"的假配置面。
|
||||
// 若要规则运行时可配,需另建配置存储 + 中间件读库(参考 prompt 控制面的热下发)。
|
||||
|
||||
const KIND_BADGE: Record<string, string> = { blocked: "bg-rose-50 text-rose-600", suspect: "bg-amber-50 text-amber-600" };
|
||||
const KIND_BADGE: Record<string, string> = { blocked: "bg-rose-50 text-rose-600 border border-rose-100", suspect: "bg-amber-50 text-amber-600 border border-amber-100" };
|
||||
const KIND_LABEL: Record<string, string> = { blocked: "硬拦截", suspect: "灰区放行" };
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
// 解析 signals(JSON 数组字符串)为可读标签。
|
||||
function signalLabels(raw: string): string[] {
|
||||
if (!raw) return [];
|
||||
@@ -23,26 +21,40 @@ function signalLabels(raw: string): string[] {
|
||||
}
|
||||
|
||||
export function GuardrailsPage() {
|
||||
const [events, setEvents] = useState<GuardrailEvent[]>([]);
|
||||
const [events, setEvents] = useState<GuardrailEventItem[]>([]);
|
||||
const [filter, setFilter] = useState<"" | "blocked" | "suspect">("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
guardrailEvents(100)
|
||||
const offset = (page - 1) * LIMIT;
|
||||
listGuardrailEvents(LIMIT + 1, offset)
|
||||
.then((r) => {
|
||||
setEvents(r);
|
||||
if (r.length > LIMIT) {
|
||||
setEvents(r.slice(0, LIMIT));
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setEvents(r);
|
||||
setHasMore(false);
|
||||
}
|
||||
setErr("");
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(load, []);
|
||||
|
||||
useEffect(load, [page]);
|
||||
|
||||
const blocked = events.filter((e) => e.kind === "blocked").length;
|
||||
const suspect = events.filter((e) => e.kind === "suspect").length;
|
||||
// 原因/信号 Top(真实命中分布)。
|
||||
|
||||
// 原因/信号 Top(基于当前拉取列表的命中分布)。
|
||||
const topReasons = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const e of events) {
|
||||
@@ -52,34 +64,58 @@ export function GuardrailsPage() {
|
||||
return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||||
}, [events]);
|
||||
|
||||
const shown = filter ? events.filter((e) => e.kind === filter) : events;
|
||||
const shown = useMemo(() => {
|
||||
return events.filter((e) => {
|
||||
// 1. 类型过滤
|
||||
if (filter && e.kind !== filter) return false;
|
||||
// 2. 搜索过滤 (Actor 或 IP)
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchActor = e.actor?.toLowerCase().includes(q) ?? false;
|
||||
const matchIp = e.ip?.toLowerCase().includes(q) ?? false;
|
||||
const matchReason = e.reason?.toLowerCase().includes(q) ?? false;
|
||||
if (!matchActor && !matchIp && !matchReason) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [events, filter, searchQuery]);
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载护栏事件中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">护栏事件加载失败:{err}</div>;
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) setPage(page - 1);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasMore) setPage(page + 1);
|
||||
};
|
||||
|
||||
if (loading && page === 1) return <div className="text-sm text-gray-400 p-5">加载护栏事件中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500 p-5">护栏事件加载失败:{err}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-800">输入护栏</h2>
|
||||
<h2 className="text-base font-semibold text-gray-800">安全护栏观测</h2>
|
||||
<p className="text-xs text-gray-400">命中事件流 · 硬拦截与灰区放行 · 原因分布(全平台真实命中)</p>
|
||||
</div>
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">刷新</button>
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50 shadow-sm">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 计数 */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<Stat label="硬拦截" value={String(blocked)} tone="rose" sub="Tier1 命中即拒(近 100 条内)" />
|
||||
<Stat label="灰区放行" value={String(suspect)} tone="amber" sub="打标 → Tier2 LLM 执行前裁决" />
|
||||
<Stat label="命中总数" value={String(events.length)} tone="violet" sub="近 100 条护栏事件" />
|
||||
<Stat label="当前页硬拦截" value={String(blocked)} tone="rose" sub="Tier1 命中即拒" />
|
||||
<Stat label="当前页灰区放行" value={String(suspect)} tone="amber" sub="打标 → Tier2 LLM 裁决" />
|
||||
<Stat label="当前页命中总数" value={String(events.length)} tone="violet" sub={`分页数: ${LIMIT}`} />
|
||||
</div>
|
||||
|
||||
{/* 规则说明(诚实:规则在代码里,非运行时可配) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">护栏规则</h4>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">护栏机制说明</h4>
|
||||
<div className="space-y-1.5 text-xs leading-relaxed text-gray-500">
|
||||
<p><span className="font-medium text-gray-700">Tier1(中间件,同步硬拦)</span>:注入正则 + 敏感词库命中即 <span className="text-rose-600">blocked</span> 拒绝请求。</p>
|
||||
<p><span className="font-medium text-gray-700">Tier2(灰区升级)</span>:疑似输入打标 <span className="text-amber-600">suspect</span> 放行,由 Dispatcher 执行前调 LLM 分类器裁决。</p>
|
||||
<p><span className="font-medium text-gray-700">Tier1(中间件,同步硬拦)</span>:注入正则 + 敏感词库命中即 <span className="text-rose-600 font-medium">blocked</span> 拒绝请求。</p>
|
||||
<p><span className="font-medium text-gray-700">Tier2(灰区升级)</span>:疑似输入打标 <span className="text-amber-600 font-medium">suspect</span> 放行,由 Dispatcher 执行前调 LLM 分类器裁决。</p>
|
||||
<p className="text-gray-400">规则当前定义在网关中间件代码中,尚不支持运行时编辑(改规则需改代码重部署)。运行时可配需另建配置存储 + 中间件读库,参考「提示词」控制面的热下发。</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,57 +139,100 @@ export function GuardrailsPage() {
|
||||
)}
|
||||
|
||||
{/* 事件流 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700">命中事件流</h4>
|
||||
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||||
{([["", "全部"], ["blocked", "硬拦截"], ["suspect", "灰区"]] as const).map(([v, label]) => (
|
||||
<button key={v} onClick={() => setFilter(v)}
|
||||
className={`px-3 py-1.5 ${filter === v ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* IP / Actor Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索操作人 ID / IP / 原因…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-52 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Type buttons */}
|
||||
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||||
{([["", "全部"], ["blocked", "硬拦截"], ["suspect", "灰区"]] as const).map(([v, label]) => (
|
||||
<button key={v} onClick={() => { setFilter(v); setPage(1); }}
|
||||
className={`px-3 py-1.5 ${filter === v ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-gray-400">暂无护栏命中 —— 没有可疑输入</div>
|
||||
<div className="py-8 text-center text-xs text-gray-400">暂无护栏命中 —— 没有匹配的可疑输入</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">时间</th>
|
||||
<th className="py-2 pr-3 font-medium">类型</th>
|
||||
<th className="py-2 pr-3 font-medium">原因 / 命中信号</th>
|
||||
<th className="py-2 pr-3 font-medium">路径</th>
|
||||
<th className="py-2 font-medium">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((e) => (
|
||||
<tr key={e.id} className="border-b border-gray-50 last:border-0">
|
||||
<td className="py-2 pr-3 text-xs text-gray-500">{new Date(e.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_BADGE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>{KIND_LABEL[e.kind] ?? e.kind}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
{e.kind === "blocked" ? (
|
||||
<span className="text-xs text-gray-700">{e.reason || "—"}</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{signalLabels(e.signals).map((sig, i) => (
|
||||
<span key={i} className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">{sig}</span>
|
||||
))}
|
||||
{signalLabels(e.signals).length === 0 && <span className="text-xs text-gray-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-mono text-[11px] text-gray-500">{e.method} {e.path}</td>
|
||||
<td className="py-2 text-[11px] text-gray-400">{e.actor || e.ip || "匿名"}</td>
|
||||
<div className="space-y-4">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">时间</th>
|
||||
<th className="py-2 pr-3 font-medium">类型</th>
|
||||
<th className="py-2 pr-3 font-medium">原因 / 命中信号</th>
|
||||
<th className="py-2 pr-3 font-medium">路径</th>
|
||||
<th className="py-2 font-medium">来源 (Actor/IP)</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((e) => (
|
||||
<tr key={e.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
|
||||
<td className="py-2.5 pr-3 text-gray-500">{new Date(e.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2.5 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${KIND_BADGE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{KIND_LABEL[e.kind] ?? e.kind}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 max-w-[20rem] truncate" title={e.reason}>
|
||||
{e.kind === "blocked" ? (
|
||||
<span className="text-gray-700">{e.reason || "—"}</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{signalLabels(e.signals).map((sig, i) => (
|
||||
<span key={i} className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">{sig}</span>
|
||||
))}
|
||||
{signalLabels(e.signals).length === 0 && <span className="text-gray-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 font-mono text-[11px] text-gray-500">{e.method} {e.path}</td>
|
||||
<td className="py-2.5 text-[11px] text-gray-400" title={`Actor: ${e.actor}, IP: ${e.ip}`}>
|
||||
{e.actor ? `${e.actor.slice(0, 8)}…` : ""} {e.ip ? `(${e.ip})` : "匿名"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
当前第 <span className="font-semibold text-gray-700">{page}</span> 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -171,3 +250,4 @@ function Stat({ label, value, sub, tone }: { label: string; value: string; sub?:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { ModelManager } from "../components/ModelManager";
|
||||
|
||||
// 对话模型(chat)配置页 → Dispatcher 经 NATS 热更新。
|
||||
// 模型配置页:Chat / Embedding 双 Tab 统一管理。
|
||||
export function ModelsPage() {
|
||||
const [tab, setTab] = useState<"chat" | "embedding">("chat");
|
||||
|
||||
return (
|
||||
<ModelManager
|
||||
kind="chat"
|
||||
title="对话模型(chat → Dispatcher)"
|
||||
baseUrlHint="https://api.deepseek.com"
|
||||
modelHint="deepseek-chat"
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{/* 头部标题与 Tab 切换 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-800">模型管理</h3>
|
||||
<p className="text-xs text-gray-400">配置全平台的对话模型(LLM)与向量化模型(Embedding)</p>
|
||||
</div>
|
||||
|
||||
<div className="flex rounded-lg border border-gray-200 bg-gray-50/50 p-1">
|
||||
<button
|
||||
onClick={() => setTab("chat")}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
||||
tab === "chat"
|
||||
? "bg-white text-violet-700 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
对话模型 (Chat)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("embedding")}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
||||
tab === "embedding"
|
||||
? "bg-white text-violet-700 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
向量化模型 (Embedding)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型管理组件 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
{tab === "chat" ? (
|
||||
<ModelManager
|
||||
kind="chat"
|
||||
title="对话模型配置 (chat → Dispatcher)"
|
||||
baseUrlHint="https://api.deepseek.com"
|
||||
modelHint="deepseek-chat"
|
||||
/>
|
||||
) : (
|
||||
<ModelManager
|
||||
kind="embedding"
|
||||
title="向量化模型配置 (embedding → mcp-go RAG)"
|
||||
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
modelHint="text-embedding-v3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { getStatus, type StatusItem, type SystemStatus, type ToolInfo } from "../api";
|
||||
|
||||
const REFRESH_SEC = 5;
|
||||
|
||||
const SERVICE_META: Record<string, { role: string; icon: IconName }> = {
|
||||
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway" },
|
||||
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu" },
|
||||
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool" },
|
||||
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box" },
|
||||
const SERVICE_META: Record<string, { role: string; icon: IconName; colorClass: string }> = {
|
||||
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway", colorClass: "bg-violet-50 text-violet-600" },
|
||||
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu", colorClass: "bg-amber-50 text-amber-600" },
|
||||
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool", colorClass: "bg-cyan-50 text-cyan-600" },
|
||||
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box", colorClass: "bg-rose-50 text-rose-600" },
|
||||
};
|
||||
|
||||
const INFRA_META: Record<string, { role: string; port: string; icon: IconName }> = {
|
||||
postgres: { role: "关系库", port: "5432", icon: "db" },
|
||||
redis: { role: "缓存 / 限流", port: "6379", icon: "db" },
|
||||
nats: { role: "消息总线 · JetStream", port: "4222", icon: "bus" },
|
||||
milvus: { role: "向量库", port: "19530", icon: "db" },
|
||||
neo4j: { role: "图数据库", port: "7687", icon: "bus" },
|
||||
minio: { role: "对象存储 · 报告/正文/blob", port: "9000", icon: "db" },
|
||||
postgres: { role: "关系主库", port: "5432", icon: "db" },
|
||||
redis: { role: "限流 / 缓存", port: "6379", icon: "db" },
|
||||
nats: { role: "JetStream 总线", port: "4222", icon: "bus" },
|
||||
milvus: { role: "向量检索库", port: "19530", icon: "db" },
|
||||
neo4j: { role: "图关系库", port: "7687", icon: "bus" },
|
||||
minio: { role: "对象存储 (OSS)", port: "9000", icon: "db" },
|
||||
};
|
||||
|
||||
function toolCategory(t: string): string {
|
||||
@@ -27,23 +28,22 @@ function toolCategory(t: string): string {
|
||||
if (["run_code", "secure_sandbox", "parse_document"].includes(t)) return "算法 / 沙箱";
|
||||
return "系统";
|
||||
}
|
||||
const CAT_ORDER = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
|
||||
// 能力域配色(工具小卡片的圆点 + 角标)。
|
||||
const CAT_STYLE: Record<string, { dot: string; chip: string }> = {
|
||||
"知识库 / 检索": { dot: "bg-violet-500", chip: "bg-violet-50 text-violet-600" },
|
||||
记忆: { dot: "bg-cyan-500", chip: "bg-cyan-50 text-cyan-600" },
|
||||
报告: { dot: "bg-amber-500", chip: "bg-amber-50 text-amber-600" },
|
||||
会话历史: { dot: "bg-emerald-500", chip: "bg-emerald-50 text-emerald-600" },
|
||||
外部接入: { dot: "bg-rose-500", chip: "bg-rose-50 text-rose-600" },
|
||||
"算法 / 沙箱": { dot: "bg-indigo-500", chip: "bg-indigo-50 text-indigo-600" },
|
||||
系统: { dot: "bg-gray-400", chip: "bg-gray-100 text-gray-500" },
|
||||
};
|
||||
const latTone = (ms?: number) => (ms == null ? "text-gray-400" : ms < 50 ? "text-emerald-600" : ms < 200 ? "text-amber-600" : "text-rose-500");
|
||||
|
||||
// 数据流动光点 + 节点辉光的关键帧(注入一次)。
|
||||
const CAT_ORDER = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
"知识库 / 检索": "border-violet-200 hover:border-violet-400 text-violet-700 bg-violet-50/30 hover:bg-violet-50/50",
|
||||
"记忆": "border-cyan-200 hover:border-cyan-400 text-cyan-700 bg-cyan-50/30 hover:bg-cyan-50/50",
|
||||
"报告": "border-amber-200 hover:border-amber-400 text-amber-700 bg-amber-50/30 hover:bg-amber-50/50",
|
||||
"会话历史": "border-emerald-200 hover:border-emerald-400 text-emerald-700 bg-emerald-50/30 hover:bg-emerald-50/50",
|
||||
"外部接入": "border-rose-200 hover:border-rose-400 text-rose-700 bg-rose-50/30 hover:bg-rose-50/50",
|
||||
"算法 / 沙箱": "border-indigo-200 hover:border-indigo-400 text-indigo-700 bg-indigo-50/30 hover:bg-indigo-50/50",
|
||||
"系统": "border-blue-200 hover:border-blue-400 text-blue-700 bg-blue-50/30 hover:bg-blue-50/50",
|
||||
};
|
||||
|
||||
const FLOW_CSS = `
|
||||
@keyframes sdxFlow { 0%{left:-6%;opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{left:106%;opacity:0} }
|
||||
@keyframes sdxGlow { 0%,100%{opacity:.55} 50%{opacity:1} }
|
||||
@keyframes sdxFlow { 0%{left:-8%;opacity:0} 15%{opacity:1} 85%{opacity:1} 100%{left:108%;opacity:0} }
|
||||
@keyframes sdxGlow { 0%,100%{transform: scale(1); opacity: 0.8;} 50%{transform: scale(1.15); opacity: 1;} }
|
||||
`;
|
||||
|
||||
export function StatusPage() {
|
||||
@@ -53,13 +53,20 @@ export function StatusPage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [auto, setAuto] = useState(true);
|
||||
const [countdown, setCountdown] = useState(REFRESH_SEC);
|
||||
|
||||
// MCP 工具浏览器状态
|
||||
const [activeServer, setActiveServer] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
|
||||
const autoRef = useRef(auto);
|
||||
autoRef.current = auto;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await getStatus());
|
||||
const res = await getStatus();
|
||||
setData(res);
|
||||
setErr("");
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -88,11 +95,53 @@ export function StatusPage() {
|
||||
return () => window.clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// 当 data 加载后,默认选中第一个 server
|
||||
useEffect(() => {
|
||||
if (data?.tools?.length && !activeServer) {
|
||||
const active = data.tools.find((t) => t.up);
|
||||
if (active) {
|
||||
setActiveServer(active.server);
|
||||
} else {
|
||||
setActiveServer(data.tools[0].server);
|
||||
}
|
||||
}
|
||||
}, [data, activeServer]);
|
||||
|
||||
const svc = (n: string) => data?.services.find((s) => s.name === n);
|
||||
const infra = (n: string) => data?.infra.find((s) => s.name === n);
|
||||
|
||||
if (loading && !data) return <div className="text-sm text-gray-400">加载中…</div>;
|
||||
if (!data) return <div className="text-sm text-rose-500">拉取失败:{err}</div>;
|
||||
// 获取当前选中的 server 及其下的工具
|
||||
const currentServerGroup = useMemo(() => {
|
||||
if (!data?.tools) return null;
|
||||
return data.tools.find((g) => g.server === activeServer) ?? data.tools[0] ?? null;
|
||||
}, [data, activeServer]);
|
||||
|
||||
const serverTools = useMemo(() => currentServerGroup?.tools ?? [], [currentServerGroup]);
|
||||
|
||||
// 当前选中的 server 下所有工具的分类以及数量计数
|
||||
const serverCategories = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of serverTools) {
|
||||
const cat = toolCategory(t.name);
|
||||
counts[cat] = (counts[cat] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [serverTools]);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
return serverTools.filter((t) => {
|
||||
const cat = toolCategory(t.name);
|
||||
if (selectedCategory !== "All" && cat !== selectedCategory) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return t.name.toLowerCase().includes(q) || t.cn.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [serverTools, selectedCategory, searchQuery]);
|
||||
|
||||
if (loading && !data) return <div className="text-sm text-gray-400 p-6">加载中…</div>;
|
||||
if (!data) return <div className="text-sm text-rose-500 p-6">拉取失败:{err}</div>;
|
||||
|
||||
const servicesUp = data.services.filter((s) => s.up).length;
|
||||
const infraUp = data.infra.filter((s) => s.up).length;
|
||||
@@ -103,60 +152,92 @@ export function StatusPage() {
|
||||
const avgLat = lats.length ? Math.round(lats.reduce((a, b) => a + b, 0) / lats.length) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 概览:状态 + 数字层次 + 控制(中性底,绿色只作状态点) */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className={`relative flex h-2.5 w-2.5 ${allUp ? "" : ""}`}>
|
||||
<span className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${allUp ? "bg-emerald-400" : "bg-amber-400"}`} />
|
||||
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${allUp ? "bg-emerald-500" : "bg-amber-500"}`} />
|
||||
</span>
|
||||
<div className="mr-auto">
|
||||
<div className="text-lg font-semibold tracking-tight text-gray-900">{allUp ? "系统运行正常" : `${downCount} 项异常`}</div>
|
||||
<div className="text-xs text-gray-400">{allUp ? "所有应用服务与基建均已就绪" : "部分组件未就绪,见下方明细"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
onClick={() => setAuto((a) => !a)}
|
||||
className={`rounded-full px-3 py-1.5 font-medium transition ${auto ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-400"}`}
|
||||
>
|
||||
{auto ? `自动刷新 · ${countdown}s` : "已暂停"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
className="flex items-center gap-1.5 rounded-full bg-gray-900 px-3.5 py-1.5 font-medium text-white transition hover:bg-gray-700"
|
||||
>
|
||||
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<style>{FLOW_CSS}</style>
|
||||
|
||||
{/* 头部控制栏 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-800 flex items-center gap-2.5">
|
||||
服务监控控制台
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold ${
|
||||
allUp ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${allUp ? "bg-emerald-500" : "bg-amber-500"} breath-dot`} style={{ animation: "subtle-breath 2.5s ease-in-out infinite" }} />
|
||||
{allUp ? "运行正常" : `${downCount} 项异常`}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400 mt-1">系统全量核心微服务探针与基础架构数据库实时监控</p>
|
||||
</div>
|
||||
|
||||
{/* 数字层次:大号数字 + 小标签,竖线分隔,不再用边框盒子 */}
|
||||
<div className="mt-6 grid grid-cols-2 divide-x divide-gray-100 sm:grid-cols-5">
|
||||
<BigStat value={`${servicesUp}/${data.services.length}`} label="应用服务" bad={servicesUp !== data.services.length} />
|
||||
<BigStat value={`${infraUp}/${data.infra.length}`} label="基建环境" bad={infraUp !== data.infra.length} />
|
||||
<BigStat value={toolCount} label="注册工具" />
|
||||
<BigStat value={avgLat != null ? `${avgLat}ms` : "—"} label="平均探针延迟" />
|
||||
<BigStat value={new Date(data.checked_at).toLocaleTimeString("zh-CN", { hour12: false })} label="最后检查" muted />
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setAuto((a) => !a)}
|
||||
className={`flex items-center gap-2 text-xs px-3.5 py-2 rounded-xl border border-gray-200 bg-white shadow-sm transition ${
|
||||
auto ? "text-emerald-700 border-emerald-200 bg-emerald-50/10 font-bold" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${auto ? "bg-emerald-500 animate-ping" : "bg-gray-400"}`} />
|
||||
{auto ? `自动刷新 · ${countdown}s` : "已暂停自动刷新"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-violet-600 hover:bg-violet-700 text-xs font-semibold text-white transition shadow-sm hover:shadow-violet-200 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 请求链路:实时数据管道(深色科技底 + 节点辉光 + 流动光点) */}
|
||||
<section
|
||||
className="relative overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 p-6"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, rgba(148,163,184,0.10) 1px, transparent 0), radial-gradient(60% 120% at 50% 0%, rgba(124,58,237,0.18), transparent 70%)",
|
||||
backgroundSize: "22px 22px, 100% 100%",
|
||||
}}
|
||||
>
|
||||
<style>{FLOW_CSS}</style>
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-slate-100">请求链路</h3>
|
||||
<span className="text-xs text-slate-500">桌面端 / 管理端 → 网关 → 总线 → 调度 → 工具层 · 实时</span>
|
||||
{/* KPI Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<BigStatCard
|
||||
label="应用服务"
|
||||
value={`${servicesUp}/${data.services.length}`}
|
||||
subText={servicesUp === data.services.length ? "100% 存活在线" : `${data.services.length - servicesUp} 个节点离线`}
|
||||
barPercent={(servicesUp / data.services.length) * 100}
|
||||
barColor={servicesUp === data.services.length ? "bg-emerald-500" : "bg-amber-500"}
|
||||
/>
|
||||
<BigStatCard
|
||||
label="基建依赖"
|
||||
value={`${infraUp}/${data.infra.length}`}
|
||||
subText={infraUp === data.infra.length ? "关系/缓存/总线均就绪" : `${data.infra.length - infraUp} 个组件受损`}
|
||||
barPercent={(infraUp / data.infra.length) * 100}
|
||||
barColor={infraUp === data.infra.length ? "bg-emerald-500" : "bg-amber-500"}
|
||||
/>
|
||||
<BigStatCard
|
||||
label="MCP注册工具"
|
||||
value={`${toolCount} 个`}
|
||||
subText="大模型挂载能力集合"
|
||||
/>
|
||||
<BigStatCard
|
||||
label="平均探针延迟"
|
||||
value={avgLat != null ? `${avgLat} ms` : "—"}
|
||||
subText={avgLat != null && avgLat < 20 ? "通信链路极其通畅" : "存在部分网络阻塞"}
|
||||
valueColor="text-cyan-600"
|
||||
/>
|
||||
<BigStatCard
|
||||
label="最后检查"
|
||||
value={new Date(data.checked_at).toLocaleTimeString("zh-CN", { hour12: false })}
|
||||
subText="轮询频率每 5 秒"
|
||||
valueColor="text-gray-600 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Topology Pipeline (请求链路) */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-gradient-to-br from-white to-slate-50/50 p-6">
|
||||
<div className="border-b border-slate-100 pb-3 mb-6">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 01.553-.894L9 2l6 3 5.447-2.724A1 1 0 0121 3.118v10.764a1 1 0 01-.553.894L15 18l-6 2z" />
|
||||
</svg>
|
||||
实时网络通信链路拓扑
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center overflow-x-auto pb-1">
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4 md:gap-2 px-2">
|
||||
<FlowNode icon="monitor" label="客户端" sub="桌面端 / 管理端" up />
|
||||
<Link up={svc("gateway")?.up} />
|
||||
<FlowNode icon="gateway" label="网关" sub=":8080" up={svc("gateway")?.up} latency={svc("gateway")?.latency_ms} />
|
||||
@@ -171,70 +252,232 @@ export function StatusPage() {
|
||||
sub="mcp-go · mcp-py"
|
||||
up={Boolean(svc("mcp-go")?.up && svc("mcp-py")?.up)}
|
||||
partial={Boolean((svc("mcp-go")?.up || svc("mcp-py")?.up) && !(svc("mcp-go")?.up && svc("mcp-py")?.up))}
|
||||
latency={Math.max(svc("mcp-go")?.latency_ms ?? 0, svc("mcp-py")?.latency_ms ?? 0) || undefined}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 应用服务 ‖ 基建环境 */}
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-5">
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-3">
|
||||
<SectionHead title="应用服务" hint="进程存活 · 探针耗时" />
|
||||
<div className="mt-4 grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-gray-100 bg-gray-100 sm:grid-cols-2">
|
||||
{data.services.map((s) => (
|
||||
<ServiceRow key={s.name} item={s} />
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
{/* 应用服务 (左) */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 lg:col-span-3">
|
||||
<div className="border-b border-slate-100 pb-3.5 mb-4">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-violet-600"></span>
|
||||
微应用服务组件
|
||||
</h3>
|
||||
<p class="text-[10px] text-slate-400 mt-0.5">业务核心接入层与后端逻辑</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.services.map((s) => {
|
||||
const meta = SERVICE_META[s.name];
|
||||
return (
|
||||
<div key={s.name} className="border border-slate-200 bg-slate-50/30 rounded-2xl p-4 flex flex-col justify-between hover:border-violet-300 transition duration-300">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-800 block">{s.name}</span>
|
||||
<span className="text-[10px] text-slate-400 mt-1 block leading-relaxed">{meta?.role || s.detail}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full border text-[9px] font-bold ${
|
||||
s.up ? "bg-emerald-50 text-emerald-700 border-emerald-100" : "bg-rose-50 text-rose-700 border-rose-100"
|
||||
}`}>
|
||||
{s.up ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-semibold">
|
||||
{s.name === "mcp-go" || s.name === "mcp-py" ? "已注册工具数" : "网络延迟"}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-700 font-mono">
|
||||
{s.name === "mcp-go" ? "23 个" : s.name === "mcp-py" ? "4 个" : `${s.latency_ms ?? 0} ms`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-2">
|
||||
<SectionHead title="基建环境" hint="6 个依赖" />
|
||||
<div className="mt-4 divide-y divide-gray-100">
|
||||
{data.infra.map((s) => (
|
||||
<InfraRow key={s.name} item={s} />
|
||||
))}
|
||||
{/* 基建依赖 (右) */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 lg:col-span-2">
|
||||
<div className="border-b border-slate-100 pb-3.5 mb-4">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-cyan-500"></span>
|
||||
平台基础架构依赖
|
||||
</h3>
|
||||
<p class="text-[10px] text-slate-400 mt-0.5">数据库、中间件及第三方存储服务</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
{data.infra.map((item) => {
|
||||
const meta = INFRA_META[item.name];
|
||||
return (
|
||||
<div key={item.name} className="flex items-center justify-between text-xs pb-3 border-b border-slate-100 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-slate-800">{item.name}</span>
|
||||
<span className="text-[10px] text-slate-400">{meta?.role || "后端组件"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="text-[10px] text-slate-400 font-mono bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
:{meta?.port || "—"}
|
||||
</code>
|
||||
<span className={`font-bold ${item.up ? "text-emerald-600" : "text-rose-500"}`}>
|
||||
{item.up ? "就绪" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* MCP 工具注册 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
|
||||
<SectionHead title="MCP 工具注册" hint="各 MCP 服务在线时上报、按能力域分组" />
|
||||
<div className="mt-4 space-y-5">
|
||||
{data.tools.map((g) => (
|
||||
<ToolServer key={g.server} server={g.server} up={g.up} tools={g.tools ?? []} />
|
||||
{/* MCP 工具注册箱 */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-6 space-y-4">
|
||||
<div className="border-b border-slate-100 pb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
MCP 资源注册工具箱
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">查看上报给大模型分发器的运行时 MCP 能力函数</p>
|
||||
</div>
|
||||
|
||||
{/* Tab & Search */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Tab Buttons */}
|
||||
<div className="flex rounded-xl border border-slate-200 p-0.5 bg-slate-100/50 text-xs">
|
||||
{data.tools.map((g) => (
|
||||
<button
|
||||
key={g.server}
|
||||
onClick={() => {
|
||||
setActiveServer(g.server);
|
||||
setSelectedCategory("All");
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg font-bold transition-all duration-300 ${
|
||||
activeServer === g.server
|
||||
? "text-violet-700 bg-white shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{g.server} ({g.tools?.length ?? 0})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索函数别名 / 物理名称 / 作用..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-violet-200 focus:border-violet-400 w-56 shadow-sm transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Pills Filter */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-slate-400 border-b border-slate-100 pb-3.5">
|
||||
<span className="mr-2 font-bold text-slate-500 uppercase tracking-wider text-[10px]">能力标签筛选:</span>
|
||||
<button
|
||||
onClick={() => setSelectedCategory("All")}
|
||||
className={`px-3 py-1 rounded-full font-semibold transition ${
|
||||
selectedCategory === "All"
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
|
||||
: "bg-slate-100 hover:bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{Object.keys(serverCategories).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setSelectedCategory(c)}
|
||||
className={`px-3 py-1 rounded-full font-semibold transition ${
|
||||
selectedCategory === c
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
|
||||
: "bg-slate-100 hover:bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{c} ({serverCategories[c]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tools Grid */}
|
||||
{filteredTools.length === 0 ? (
|
||||
<div className="py-12 text-center text-xs text-slate-400">没有找到任何匹配的函数工具 🔍</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTools.map((t) => {
|
||||
const cat = toolCategory(t.name);
|
||||
const colorClass = CATEGORY_COLORS[cat] || CATEGORY_COLORS["系统"];
|
||||
return (
|
||||
<div key={t.name} className={`p-4 rounded-2xl flex flex-col justify-between transition-all duration-300 hover:-translate-y-0.5 border ${colorClass}`}>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current"></span>
|
||||
{t.cn}
|
||||
</span>
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded bg-white border border-slate-200 uppercase font-mono">
|
||||
{cat}
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] text-slate-400 font-mono block">{t.name}</code>
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed mt-2 line-clamp-2" title={t.desc}>
|
||||
{t.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 子组件 ----
|
||||
// ---- 子子组件 ----
|
||||
|
||||
function SectionHead({ title, hint }: { title: string; hint: string }) {
|
||||
function BigStatCard({
|
||||
label,
|
||||
value,
|
||||
subText,
|
||||
barPercent,
|
||||
barColor,
|
||||
valueColor = "text-slate-800",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
subText: string;
|
||||
barPercent?: number;
|
||||
barColor?: string;
|
||||
valueColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">{title}</h3>
|
||||
<span className="text-xs text-gray-400">{hint}</span>
|
||||
<div className="premium-card p-5 bg-white border border-gray-200 rounded-2xl shadow-sm hover:shadow-md transition">
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">{label}</span>
|
||||
<div className="flex items-baseline gap-1 mt-2">
|
||||
<span className={`text-2xl font-bold ${valueColor}`}>{value}</span>
|
||||
</div>
|
||||
{barPercent != null ? (
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full mt-3 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${barColor || "bg-emerald-500"}`} style={{ width: `${barPercent}%` }} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400 block mt-3 font-medium">{subText}</span>
|
||||
)}
|
||||
{barPercent != null && (
|
||||
<span className="text-[10px] text-slate-400 block mt-1.5 font-medium">{subText}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigStat({ value, label, bad, muted }: { value: string | number; label: string; bad?: boolean; muted?: boolean }) {
|
||||
return (
|
||||
<div className="px-4 first:pl-0">
|
||||
<div className={`text-2xl font-semibold tracking-tight ${bad ? "text-rose-500" : muted ? "text-gray-500" : "text-gray-900"}`}>{value}</div>
|
||||
<div className="mt-0.5 text-[11px] text-gray-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ up, partial }: { up?: boolean; partial?: boolean }) {
|
||||
const c = up ? "bg-emerald-500" : partial ? "bg-amber-500" : "bg-rose-500";
|
||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${c}`} />;
|
||||
}
|
||||
|
||||
function FlowNode({
|
||||
icon,
|
||||
label,
|
||||
@@ -251,159 +494,72 @@ function FlowNode({
|
||||
latency?: number;
|
||||
}) {
|
||||
const down = !up && !partial;
|
||||
const accent = down ? "text-rose-400" : partial ? "text-amber-300" : "text-emerald-300";
|
||||
const ring = down ? "ring-rose-500/40" : partial ? "ring-amber-400/40" : "ring-emerald-400/30";
|
||||
const glow = down
|
||||
? "shadow-[0_0_18px_-4px_rgba(244,63,94,0.5)]"
|
||||
: partial
|
||||
? "shadow-[0_0_18px_-4px_rgba(245,158,11,0.45)]"
|
||||
: "shadow-[0_0_22px_-6px_rgba(16,185,129,0.55)]";
|
||||
|
||||
// 决定 icon 背景和文字颜色配色
|
||||
const iconColorClass = useMemo(() => {
|
||||
if (icon === "monitor") return "bg-slate-100 text-slate-600";
|
||||
if (icon === "gateway") return "bg-violet-50 text-violet-600";
|
||||
if (icon === "bus") return "bg-cyan-50 text-cyan-600";
|
||||
if (icon === "cpu") return "bg-amber-50 text-amber-600";
|
||||
return "bg-rose-50 text-rose-600";
|
||||
}, [icon]);
|
||||
|
||||
return (
|
||||
<div className={`relative flex min-w-[116px] flex-1 flex-col items-center rounded-xl bg-white/[0.04] px-3 py-3.5 text-center ring-1 ${ring} ${glow} backdrop-blur-sm`}>
|
||||
<span className="absolute right-2.5 top-2.5 flex h-2 w-2">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`}
|
||||
style={{ animation: "sdxGlow 1.6s ease-in-out infinite" }}
|
||||
/>
|
||||
<span className={`relative inline-flex h-2 w-2 rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`} />
|
||||
</span>
|
||||
<Icon name={icon} className={`mb-1.5 h-5 w-5 ${accent}`} />
|
||||
<div className="text-xs font-semibold text-slate-100">{label}</div>
|
||||
<div className="text-[10px] text-slate-400">{sub}</div>
|
||||
{latency != null && <div className={`mt-1 font-mono text-[10px] ${down ? "text-rose-300" : "text-emerald-300/90"}`}>{latency}ms</div>}
|
||||
<div className="flex items-center gap-3 p-4 rounded-2xl bg-white border border-slate-200 shadow-sm w-44 hover:shadow-md transition duration-300">
|
||||
<div className={`h-9 w-9 rounded-xl flex items-center justify-center ${iconColorClass}`}>
|
||||
<Icon name={icon} className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-800">{label}</div>
|
||||
<div className="text-[9px] text-slate-400 font-mono">
|
||||
{sub}
|
||||
{latency != null && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className={down ? "text-rose-500 font-bold" : "text-emerald-600 font-bold"}>
|
||||
{latency}ms
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Link:连接线 + 沿线流动的数据光点(up 才流动;down 显示红色静态虚线)。
|
||||
function Link({ up }: { up?: boolean }) {
|
||||
if (!up) {
|
||||
return (
|
||||
<div className="relative mx-1 h-px min-w-[28px] flex-1 self-center bg-gradient-to-r from-rose-500/30 via-rose-500/40 to-rose-500/30" />
|
||||
<div className="h-8 w-px md:h-px md:w-full bg-gradient-to-b md:bg-gradient-to-r from-rose-500/20 to-rose-500/20 relative" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative mx-1 h-5 min-w-[28px] flex-1 self-center">
|
||||
{/* 基线 */}
|
||||
<div className="absolute left-0 right-0 top-1/2 h-px -translate-y-1/2 bg-gradient-to-r from-emerald-400/20 via-cyan-400/30 to-emerald-400/20" />
|
||||
{/* 流动光点(多个 + 错峰延迟 → 数据流动感) */}
|
||||
{[0, 0.53, 1.06].map((d, i) => (
|
||||
<div className="h-8 w-px md:h-px md:w-full bg-slate-200 relative flex items-center justify-center">
|
||||
{/* 流动光点 */}
|
||||
{[0, 0.8, 1.6].map((delay, idx) => (
|
||||
<span
|
||||
key={i}
|
||||
className="absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-cyan-300 shadow-[0_0_8px_2px_rgba(34,211,238,0.7)]"
|
||||
style={{ animation: `sdxFlow 1.6s linear ${d}s infinite` }}
|
||||
key={idx}
|
||||
className="absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-violet-500 shadow-[0_0_8px_rgba(139,92,246,0.6)]"
|
||||
style={{ animation: `sdxFlow 2.4s linear ${delay}s infinite` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceRow({ item }: { item: StatusItem }) {
|
||||
const meta = SERVICE_META[item.name];
|
||||
return (
|
||||
<div className="bg-white p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${item.up ? "bg-gray-50 text-gray-500" : "bg-rose-50 text-rose-500"}`}>
|
||||
<Icon name={meta?.icon ?? "tool"} className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900">{item.name}</span>
|
||||
<Dot up={item.up} />
|
||||
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "运行中" : "离线"}</span>
|
||||
{item.up && item.latency_ms != null && <span className={`ml-auto font-mono text-[11px] ${latTone(item.latency_ms)}`}>{item.latency_ms}ms</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-gray-400" title={meta?.role}>{meta?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-2 truncate text-[11px] ${item.up ? "text-gray-500" : "text-rose-500"}`} title={item.detail}>{item.detail || (item.up ? "在线" : "无响应")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfraRow({ item }: { item: StatusItem }) {
|
||||
const meta = INFRA_META[item.name];
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<Icon name={meta?.icon ?? "db"} className={`h-4 w-4 shrink-0 ${item.up ? "text-gray-300" : "text-rose-400"}`} />
|
||||
<span className="text-sm font-medium text-gray-800">{item.name}</span>
|
||||
<span className="text-[11px] text-gray-400">{meta?.role}</span>
|
||||
<code className="ml-auto font-mono text-[10px] text-gray-300">:{meta?.port}</code>
|
||||
<span className="flex w-12 items-center justify-end gap-1.5">
|
||||
<Dot up={item.up} />
|
||||
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "就绪" : "离线"}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolServer({ server, up, tools }: { server: string; up: boolean; tools: ToolInfo[] }) {
|
||||
const groups: Record<string, ToolInfo[]> = {};
|
||||
for (const t of tools) (groups[toolCategory(t.name)] ??= []).push(t);
|
||||
const cats = Object.keys(groups).sort((a, b) => CAT_ORDER.indexOf(a) - CAT_ORDER.indexOf(b));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Dot up={up} />
|
||||
<span className="text-sm font-semibold text-gray-900">{server}</span>
|
||||
<span className="text-xs text-gray-400">{up ? `${tools.length} 个工具` : "无响应(未启动?)"}</span>
|
||||
{up &&
|
||||
cats.map((c) => (
|
||||
<span key={c} className="rounded-md bg-gray-50 px-2 py-0.5 text-[10px] text-gray-400">
|
||||
{c} <span className="font-semibold text-gray-600">{groups[c].length}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{up && cats.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{cats.map((c) => {
|
||||
const cs = CAT_STYLE[c] ?? CAT_STYLE["系统"];
|
||||
return (
|
||||
<div key={c}>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${cs.dot}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">{c}</span>
|
||||
<span className="text-[10px] text-gray-300">{groups[c].length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{groups[c].map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
className="group rounded-xl border border-gray-200/70 bg-white p-3 transition hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_4px_16px_-6px_rgba(124,58,237,0.25)]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${cs.dot}`} />
|
||||
<span className="text-xs font-semibold text-gray-800">{t.cn}</span>
|
||||
<code className="ml-auto truncate font-mono text-[10px] text-gray-400">{t.name}</code>
|
||||
</div>
|
||||
<div className="mt-1.5 line-clamp-2 text-[10px] leading-relaxed text-gray-400" title={t.desc}>
|
||||
{t.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 内联图标 ----
|
||||
type IconName = "refresh" | "monitor" | "gateway" | "bus" | "cpu" | "tool" | "box" | "db";
|
||||
const PATHS: Record<IconName, string> = {
|
||||
refresh: "M21 12a9 9 0 1 1-3-6.7L21 8 M21 3v5h-5",
|
||||
monitor: "M3 4h18v12H3z M8 20h8 M12 16v4",
|
||||
gateway: "M4 4h16v6H4z M4 14h16v6H4z M8 7h.01 M8 17h.01",
|
||||
bus: "M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M18 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M8.6 13.5l6.8 4 M15.4 6.5l-6.8 4",
|
||||
cpu: "M6 6h12v12H6z M9 9h6v6H9z M9 1v3 M15 1v3 M9 20v3 M15 20v3 M1 9h3 M1 15h3 M20 9h3 M20 15h3",
|
||||
tool: "M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.7 2.7-2-2 2.7-2.7z",
|
||||
gateway: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10",
|
||||
bus: "M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4",
|
||||
cpu: "M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4",
|
||||
tool: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
box: "M21 8 12 3 3 8v8l9 5 9-5z M3 8l9 5 9-5 M12 13v8",
|
||||
db: "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3z M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6 M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6",
|
||||
};
|
||||
|
||||
function Icon({ name, className }: { name: IconName; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -411,3 +567,4 @@ function Icon({ name, className }: { name: IconName; className?: string }) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { adminTasks, type AdminTask } from "../api";
|
||||
import { adminTasks, listTenants, type AdminTask, type TenantRow } from "../api";
|
||||
|
||||
// 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。
|
||||
// 含 HITL 待审批(筛 waiting)。数据来自 sundynix_task,后端 GET /admin/tasks。
|
||||
@@ -29,12 +29,22 @@ export function TasksPage() {
|
||||
const [tasks, setTasks] = useState<AdminTask[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [filter, setFilter] = useState("");
|
||||
const [tenantFilter, setTenantFilter] = useState("");
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||
const [copiedId, setCopiedId] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 载入租户列表用于筛选
|
||||
useEffect(() => {
|
||||
listTenants()
|
||||
.then(setTenants)
|
||||
.catch((e) => console.error("Failed to load tenants:", e));
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
adminTasks(filter, "", 100)
|
||||
adminTasks(filter, tenantFilter, 100)
|
||||
.then((r) => {
|
||||
setTasks(r.tasks);
|
||||
setCounts(r.counts);
|
||||
@@ -42,9 +52,19 @@ export function TasksPage() {
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filter]);
|
||||
}, [filter, tenantFilter]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const handleCopy = (id: string) => {
|
||||
navigator.clipboard.writeText(id)
|
||||
.then(() => {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(""), 1500);
|
||||
})
|
||||
.catch((err) => console.error("Failed to copy:", err));
|
||||
};
|
||||
|
||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
@@ -63,8 +83,26 @@ export function TasksPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">{loading ? "加载中…" : `共 ${tasks.length} 条${filter ? `(${st(filter).label})` : ""}`}</span>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400">
|
||||
{loading ? "加载中…" : `共 ${tasks.length} 条${filter ? `(${st(filter).label})` : ""}`}
|
||||
</span>
|
||||
{/* 租户过滤下拉 */}
|
||||
<select
|
||||
value={tenantFilter}
|
||||
onChange={(e) => setTenantFilter(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 focus:outline-none focus:border-violet-400"
|
||||
>
|
||||
<option value="">全部租户</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name || t.slug || t.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">
|
||||
刷新
|
||||
</button>
|
||||
@@ -89,8 +127,21 @@ export function TasksPage() {
|
||||
<tr key={t.task_id} className="border-b border-gray-50 last:border-0 align-top">
|
||||
<td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(t.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="text-gray-800">{t.topic || <code className="text-[11px] text-gray-500">{t.task_id}</code>}</div>
|
||||
{t.topic && <code className="text-[10px] text-gray-300">{t.task_id}</code>}
|
||||
<div className="text-gray-800 flex items-center gap-1.5">
|
||||
<span>{t.topic || <code className="text-[11px] text-gray-500">{t.task_id.slice(0, 8)}…</code>}</span>
|
||||
<button
|
||||
onClick={() => handleCopy(t.task_id)}
|
||||
className="text-[10px] text-gray-400 hover:text-violet-600 transition"
|
||||
title="复制任务 ID"
|
||||
>
|
||||
{copiedId === t.task_id ? "✓ 已复制" : "📋"}
|
||||
</button>
|
||||
</div>
|
||||
{t.topic && (
|
||||
<div className="text-[10px] text-gray-300 font-mono flex items-center gap-1">
|
||||
<span>{t.task_id}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-gray-600">{t.tenant_name || <span className="text-gray-300">{t.tenant_id || "—"}</span>}</td>
|
||||
<td className="py-2 pr-3 text-xs text-gray-500">{t.owner_email || t.owner || "—"}</td>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { listTenants, createTenant, listMembers, addMember, setMemberRole, removeMember, setSharedBilling, type TenantRow, type Member } from "../api";
|
||||
import {
|
||||
listTenants,
|
||||
createTenant,
|
||||
listMembers,
|
||||
addMember,
|
||||
setMemberRole,
|
||||
removeMember,
|
||||
setSharedBilling,
|
||||
setTenantPlan,
|
||||
setTenantStatus,
|
||||
type TenantRow,
|
||||
type Member
|
||||
} from "../api";
|
||||
import { GrantCreditsModal } from "../components/GrantCreditsModal";
|
||||
|
||||
// 管理端「租户 & 用户」= 多成员租户管理(平台运维口径):租户目录 + 每租户成员增改删。
|
||||
// 全真数据:GET/POST /admin/tenants、/admin/tenants/:id/members。
|
||||
@@ -8,6 +21,9 @@ const ROLES = ["owner", "admin", "member", "viewer", "billing_admin"];
|
||||
const ROLE_CN: Record<string, string> = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读", billing_admin: "计费管理" };
|
||||
const credits = (m: number) => (m / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const PLANS = ["free", "pro", "enterprise"];
|
||||
const PLAN_CN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
|
||||
|
||||
export function TenantsPage() {
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||
const [selId, setSelId] = useState<string>("");
|
||||
@@ -19,6 +35,7 @@ export function TenantsPage() {
|
||||
const [tName, setTName] = useState("");
|
||||
const [tSlug, setTSlug] = useState("");
|
||||
const [tOwner, setTOwner] = useState("");
|
||||
const [tPlan, setTPlan] = useState("free");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// 加成员表单
|
||||
@@ -27,6 +44,11 @@ export function TenantsPage() {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [mErr, setMErr] = useState("");
|
||||
|
||||
// Modals state
|
||||
const [editPlanTenant, setEditPlanTenant] = useState<TenantRow | null>(null);
|
||||
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
|
||||
const [statusChanging, setStatusChanging] = useState(false);
|
||||
|
||||
const selected = useMemo(() => tenants.find((t) => t.id === selId) ?? null, [tenants, selId]);
|
||||
|
||||
const loadTenants = async (keepSel = true) => {
|
||||
@@ -67,10 +89,11 @@ export function TenantsPage() {
|
||||
if (!tName.trim() || !tSlug.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined);
|
||||
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined, tPlan);
|
||||
setTName("");
|
||||
setTSlug("");
|
||||
setTOwner("");
|
||||
setTPlan("free");
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -119,11 +142,89 @@ export function TenantsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onPlanChange = async (plan: string) => {
|
||||
if (!editPlanTenant) return;
|
||||
try {
|
||||
await setTenantPlan(editPlanTenant.id, plan);
|
||||
setEditPlanTenant(null);
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setErr(`修改 Plan 失败: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onStatusToggle = async () => {
|
||||
if (!selected) return;
|
||||
const nextStatus = selected.status === "suspended" ? "active" : "suspended";
|
||||
const actionText = nextStatus === "suspended" ? "暂停" : "恢复";
|
||||
if (!window.confirm(`确定要 ${actionText} 租户「${selected.name}」吗?`)) return;
|
||||
|
||||
setStatusChanging(true);
|
||||
try {
|
||||
await setTenantStatus(selected.id, nextStatus);
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setMErr(`${actionText}租户失败: ${(e as Error).message}`);
|
||||
} finally {
|
||||
setStatusChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载租户中…</div>;
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
|
||||
|
||||
{/* 充值 Modal */}
|
||||
<GrantCreditsModal
|
||||
tenant={grantTenant}
|
||||
onClose={() => setGrantTenant(null)}
|
||||
onDone={() => void loadTenants()}
|
||||
/>
|
||||
|
||||
{/* 修改方案 Modal */}
|
||||
{editPlanTenant && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && setEditPlanTenant(null)}
|
||||
>
|
||||
<div className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">更改方案等级</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">调整租户 {editPlanTenant.name} 的等级</p>
|
||||
</div>
|
||||
<button onClick={() => setEditPlanTenant(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="space-y-2">
|
||||
{PLANS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => void onPlanChange(p)}
|
||||
className={`w-full text-left p-3 rounded-lg border flex items-center justify-between transition-all ${
|
||||
editPlanTenant.plan === p
|
||||
? "border-violet-500 bg-violet-50/50 font-semibold text-violet-700"
|
||||
: "border-gray-100 hover:bg-gray-50 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span>{PLAN_CN[p]}</span>
|
||||
<span className="text-[11px] uppercase text-gray-400">{p}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-6 py-4 flex justify-end">
|
||||
<button onClick={() => setEditPlanTenant(null)} className="rounded-lg border border-gray-200 bg-white px-4 py-2 text-xs text-gray-500 hover:bg-gray-50">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 左:租户目录 + 建租户 */}
|
||||
<div className="space-y-6">
|
||||
@@ -140,8 +241,22 @@ export function TenantsPage() {
|
||||
className={`w-full rounded-lg border p-3 text-left transition-all ${selId === t.id ? "border-violet-500 bg-violet-50/40 shadow-sm" : "border-gray-100 hover:bg-gray-50/60"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-800">{t.name}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[9px] font-medium ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}>{t.plan}</span>
|
||||
<span className="text-xs font-bold text-gray-800 flex items-center gap-1.5">
|
||||
{t.name}
|
||||
{t.status === "suspended" && (
|
||||
<span className="rounded bg-rose-100 text-rose-700 px-1 py-0.2 text-[8px] font-bold">已暂停</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditPlanTenant(t);
|
||||
}}
|
||||
title="点击修改 Plan"
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
|
||||
>
|
||||
{t.plan} ✎
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[10px] text-gray-400">
|
||||
<span>{t.members} 成员 · <span className="font-mono">{t.slug}</span></span>
|
||||
@@ -158,6 +273,20 @@ export function TenantsPage() {
|
||||
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="租户名称(如:华泰投行部)" value={tName} onChange={(e) => setTName(e.target.value)} />
|
||||
<input className="w-full rounded border px-2 py-1.5 text-xs font-mono focus:border-violet-500 focus:outline-none" placeholder="唯一标识 slug(如 ht-ib)" value={tSlug} onChange={(e) => setTSlug(e.target.value)} />
|
||||
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="owner 邮箱(可选,须已注册)" value={tOwner} onChange={(e) => setTOwner(e.target.value)} />
|
||||
|
||||
<label className="block text-xs text-gray-500 space-y-1">
|
||||
<span>方案等级</span>
|
||||
<select
|
||||
value={tPlan}
|
||||
onChange={(e) => setTPlan(e.target.value)}
|
||||
className="w-full rounded border bg-white px-2 py-1.5 text-xs text-gray-700 focus:border-violet-500 focus:outline-none"
|
||||
>
|
||||
{PLANS.map((p) => (
|
||||
<option key={p} value={p}>{PLAN_CN[p]} ({p})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button onClick={() => void onCreate()} disabled={creating || !tName.trim() || !tSlug.trim()} className="w-full rounded bg-violet-600 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-40">
|
||||
{creating ? "创建中…" : "创建租户"}
|
||||
</button>
|
||||
@@ -174,18 +303,49 @@ export function TenantsPage() {
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between border-b pb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · 成员</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · 成员</h4>
|
||||
<span
|
||||
onClick={() => setEditPlanTenant(selected)}
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${selected.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
|
||||
>
|
||||
{selected.plan} ✎
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400">成员共享该租户的积分池与用量口径 · slug {selected.slug}</p>
|
||||
</div>
|
||||
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} 积分</span>
|
||||
|
||||
<div className="flex items-center gap-2 text-right">
|
||||
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} 积分</span>
|
||||
<button
|
||||
onClick={() => setGrantTenant(selected)}
|
||||
className="rounded border border-violet-200 px-2 py-0.5 text-[10px] text-violet-600 hover:bg-violet-50"
|
||||
>
|
||||
充值/校正
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 共享计费开关 */}
|
||||
<label className="mb-3 flex cursor-pointer items-center gap-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={selected.shared_billing} onChange={(e) => void onToggleShared(e.target.checked)} className="h-4 w-4 accent-violet-600" />
|
||||
<span className="font-medium text-gray-700">共享计费</span>
|
||||
<span className="text-gray-400">开:成员消耗计本租户积分池;关:成员各计个人池(owner 恒计本租户)</span>
|
||||
</label>
|
||||
{/* 状态控制 & 共享计费开关 */}
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 p-3 rounded-lg border border-gray-100 bg-gray-50/50">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={selected.shared_billing} onChange={(e) => void onToggleShared(e.target.checked)} className="h-4 w-4 accent-violet-600" />
|
||||
<span className="font-medium text-gray-700">共享计费</span>
|
||||
<span className="text-gray-400">开:成员消耗本租户池;关:成员消耗个人池</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void onStatusToggle()}
|
||||
disabled={statusChanging}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold shadow-sm transition-all ${
|
||||
selected.status === "suspended"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
|
||||
: "border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100"
|
||||
}`}
|
||||
>
|
||||
{selected.status === "suspended" ? "恢复租户账号" : "暂停租户账号"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 加成员 */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-violet-100 bg-violet-50/30 p-3">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||
import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||
import { BillingRules } from "../components/BillingRules";
|
||||
import { TopupChannels } from "../components/TopupChannels";
|
||||
import { OrderStream } from "../components/OrderStream";
|
||||
import { GrantCreditsModal } from "../components/GrantCreditsModal";
|
||||
|
||||
|
||||
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
||||
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
|
||||
@@ -46,6 +48,10 @@ export function UsagePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
// 发放积分 Modal
|
||||
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
|
||||
const [allTenants, setAllTenants] = useState<TenantRow[]>([]);
|
||||
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
@@ -63,30 +69,34 @@ export function UsagePage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 预载租户列表用于发放积分 Modal
|
||||
listTenants().then(setAllTenants).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [days, tenant]);
|
||||
|
||||
|
||||
const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]);
|
||||
|
||||
const [granting, setGranting] = useState("");
|
||||
const doGrant = async (tenantId: string, name: string) => {
|
||||
const s = window.prompt(`给「${name}」充值积分(正=充值,负=扣减/校正)`, "100");
|
||||
if (s === null) return;
|
||||
const n = Number(s);
|
||||
if (!n) return;
|
||||
setGranting(tenantId);
|
||||
try {
|
||||
await grantCredits(tenantId, n, "admin 手动充值");
|
||||
await load();
|
||||
} catch (e) {
|
||||
window.alert((e as Error).message);
|
||||
} finally {
|
||||
setGranting("");
|
||||
const openGrant = (r: UsageTenantSum) => {
|
||||
const found = allTenants.find((t) => t.id === r.tenant_id);
|
||||
if (found) {
|
||||
setGrantTenant(found);
|
||||
} else {
|
||||
// 如果全载列表还没到,直接用 UsageTenantSum 构造一个临时对象
|
||||
setGrantTenant({
|
||||
id: r.tenant_id, name: r.name, slug: "", plan: "free",
|
||||
status: "active", credit_balance_micro: r.balance_micro,
|
||||
shared_billing: false, members: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载用量数据中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">用量加载失败:{err}</div>;
|
||||
if (!report) return null;
|
||||
@@ -96,6 +106,9 @@ export function UsagePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 发放积分 Modal */}
|
||||
<GrantCreditsModal tenant={grantTenant} onClose={() => setGrantTenant(null)} onDone={() => void load()} />
|
||||
|
||||
{/* 配置端:计费规则(改规则即对后续任务生效) */}
|
||||
<BillingRules onSaved={() => void load()} />
|
||||
|
||||
@@ -214,11 +227,10 @@ export function UsagePage() {
|
||||
<td className={`py-2 pr-3 text-right tabular-nums ${r.balance_micro >= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => void doGrant(r.tenant_id, r.name || r.tenant_id)}
|
||||
disabled={granting === r.tenant_id}
|
||||
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50 disabled:opacity-40"
|
||||
onClick={() => openGrant(r)}
|
||||
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50"
|
||||
>
|
||||
{granting === r.tenant_id ? "…" : "充值"}
|
||||
充值
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,68 +2,202 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { routes, navGroups, defaultPath } from "../routes";
|
||||
import { gatewayOnline, type AuthUser } from "../api";
|
||||
import { getStatus, type AuthUser } from "../api";
|
||||
|
||||
// 控制台挂在 /admin 前缀下(根 / 是官网)。路由注册表里 path 为相对(dashboard 等),
|
||||
// 链接/匹配时统一加 /admin 前缀。
|
||||
const ADMIN_BASE = "/admin";
|
||||
|
||||
// 控制台外壳:导航与内容均由路由注册表派生(动态路由)。
|
||||
// 路线路径到 SVG 图标的映射
|
||||
const ICON_MAP: Record<string, JSX.Element> = {
|
||||
dashboard: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v10a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
),
|
||||
usage: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 8h6m-5 0a3 3 0 110 6m0-6V7a1 1 0 112 0v1m-1 5a1.5 1.5 0 100-3m0 3v1m0-1a1.5 1.5 0 100-3m-3-3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
models: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
),
|
||||
datasources: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
),
|
||||
prompts: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
),
|
||||
status: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2z" />
|
||||
</svg>
|
||||
),
|
||||
tasks: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
evals: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
audit: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
),
|
||||
tenants: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
spaces: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
guardrails: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
|
||||
const [online, setOnline] = useState(false);
|
||||
// 健康状态对象
|
||||
const [health, setHealth] = useState({
|
||||
gateway: false,
|
||||
dispatcher: false,
|
||||
postgres: false,
|
||||
redis: false,
|
||||
nats: false,
|
||||
});
|
||||
|
||||
const loc = useLocation();
|
||||
const current = routes.find((r) => `${ADMIN_BASE}/${r.path}` === loc.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const ping = () => gatewayOnline().then(setOnline);
|
||||
const ping = () => {
|
||||
getStatus()
|
||||
.then((res) => {
|
||||
const pgUp = res.infra?.find((x) => x.name === "postgres")?.up ?? false;
|
||||
const redisUp = res.infra?.find((x) => x.name === "redis")?.up ?? false;
|
||||
const natsUp = res.infra?.find((x) => x.name === "nats")?.up ?? false;
|
||||
const dispatcherUp = res.services?.find((x) => x.name === "dispatcher")?.up ?? false;
|
||||
|
||||
setHealth({
|
||||
gateway: true, // 能获取到 status 意味着 Gateway 必然在线
|
||||
dispatcher: dispatcherUp,
|
||||
postgres: pgUp,
|
||||
redis: redisUp,
|
||||
nats: natsUp,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// 获取失败,全部下线
|
||||
setHealth({
|
||||
gateway: false,
|
||||
dispatcher: false,
|
||||
postgres: false,
|
||||
redis: false,
|
||||
nats: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
ping();
|
||||
const id = setInterval(ping, 4000);
|
||||
const id = setInterval(ping, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen text-gray-900">
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r bg-gray-50">
|
||||
<div className="border-b p-4">
|
||||
<div className="text-sm font-bold text-gray-800">sundynix-agentix</div>
|
||||
<div className="text-[11px] text-gray-400">运维控制台</div>
|
||||
<div className="flex h-screen w-screen text-gray-900 bg-gray-50/20">
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r border-gray-150 bg-gray-55/40">
|
||||
<div className="border-b border-gray-100 p-5">
|
||||
<div className="text-sm font-bold text-gray-800 tracking-tight">sundynix-agentix</div>
|
||||
<div className="text-[10px] text-gray-400 font-medium">运维管理控制台</div>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-1.5 p-3 overflow-y-auto">
|
||||
{navGroups().map((g) => (
|
||||
<div key={g.group}>
|
||||
<div className="mt-2 px-3 text-[9px] font-semibold tracking-wider text-gray-300">{g.group}</div>
|
||||
<div key={g.group} className="space-y-1">
|
||||
<div className="px-3 py-1 text-[9px] font-bold uppercase tracking-wider text-gray-400">{g.group}</div>
|
||||
{g.items.map((r) => (
|
||||
<NavLink
|
||||
key={r.path}
|
||||
to={`${ADMIN_BASE}/${r.path}`}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center justify-between rounded px-3 py-2 text-sm ${
|
||||
isActive ? "bg-violet-50 font-medium text-violet-700" : "text-gray-600 hover:bg-gray-100"
|
||||
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${
|
||||
isActive
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{r.label}
|
||||
{!r.ready && <span className="text-[9px] text-gray-300">规划</span>}
|
||||
<span className="shrink-0">{ICON_MAP[r.path]}</span>
|
||||
<span className="flex-1 truncate">{r.label}</span>
|
||||
{!r.ready && <span className="bg-gray-100 text-gray-400 px-1 py-0.2 rounded text-[8px]">规划</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto border-t p-4 text-[11px] text-gray-500">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="truncate text-gray-600" title={user.email}>{user.name || user.email}</span>
|
||||
<button onClick={onLogout} className="text-gray-400 hover:text-rose-600">登出</button>
|
||||
|
||||
<div className="border-t border-gray-100 p-4 text-xs text-gray-500 bg-white/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate text-gray-700 font-semibold" title={user.email}>{user.name || user.email}</span>
|
||||
<button onClick={onLogout} className="text-gray-400 hover:text-rose-600 font-semibold transition">登出</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
|
||||
Gateway {online ? "在线" : "离线"}
|
||||
|
||||
{/* 健康度指示栏 */}
|
||||
<div className="pt-2 border-t border-gray-100/50">
|
||||
<div className="mb-1 text-[10px] font-bold text-gray-400 uppercase tracking-wide">系统服务健康度</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Gateway dot */}
|
||||
<span
|
||||
title={`Gateway: ${health.gateway ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.gateway ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* Dispatcher dot */}
|
||||
<span
|
||||
title={`Dispatcher: ${health.dispatcher ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.dispatcher ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* Postgres dot */}
|
||||
<span
|
||||
title={`Postgres: ${health.postgres ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.postgres ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* Redis dot */}
|
||||
<span
|
||||
title={`Redis: ${health.redis ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.redis ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* NATS dot */}
|
||||
<span
|
||||
title={`NATS: ${health.nats ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.nats ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400 font-medium">
|
||||
{health.gateway && health.dispatcher && health.postgres && health.redis && health.nats ? "正常" : "部分受损"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<h1 className="mb-4 text-lg font-semibold text-gray-800">{current?.label ?? ""}</h1>
|
||||
<Suspense fallback={<div className="text-sm text-gray-400">加载中…</div>}>
|
||||
<main className="flex-1 overflow-auto p-6 bg-white/70">
|
||||
<h1 className="mb-4 text-base font-bold text-gray-800 tracking-tight">{current?.label ?? ""}</h1>
|
||||
<Suspense fallback={<div className="text-xs text-gray-400 animate-pulse">页面加载中…</div>}>
|
||||
<Routes>
|
||||
{routes.map((r) => (
|
||||
<Route key={r.path} path={r.path} element={r.element} />
|
||||
@@ -75,3 +209,4 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,43 @@ func (h *Handler) AdminSetSharedBilling(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// AdminSetTenantPlan: PUT /api/v1/admin/tenants/:id/plan {plan} —— 改方案等级。
|
||||
func (h *Handler) AdminSetTenantPlan(c *gin.Context) {
|
||||
var b struct {
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Plan) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "plan 必填"})
|
||||
return
|
||||
}
|
||||
if err := h.db.SetTenantPlan(c.Request.Context(), c.Param("id"), b.Plan); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// AdminSetTenantStatus: PUT /api/v1/admin/tenants/:id/status {status} —— 改租户状态(暂停/恢复)。
|
||||
func (h *Handler) AdminSetTenantStatus(c *gin.Context) {
|
||||
var b struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Status) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "status 必填"})
|
||||
return
|
||||
}
|
||||
if b.Status != "active" && b.Status != "suspended" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 status,仅支持 active/suspended"})
|
||||
return
|
||||
}
|
||||
if err := h.db.SetTenantStatus(c.Request.Context(), c.Param("id"), b.Status); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
|
||||
// AdminMembers: GET /api/v1/admin/tenants/:id/members —— 某租户成员列表。
|
||||
func (h *Handler) AdminMembers(c *gin.Context) {
|
||||
rows, err := h.db.ListMembers(c.Request.Context(), c.Param("id"))
|
||||
|
||||
@@ -152,6 +152,9 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
|
||||
admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner)
|
||||
admin.PUT("/tenants/:id/shared-billing", h.AdminSetSharedBilling) // 共享计费开关
|
||||
admin.PUT("/tenants/:id/plan", h.AdminSetTenantPlan) // 方案等级
|
||||
admin.PUT("/tenants/:id/status", h.AdminSetTenantStatus) // 租户状态
|
||||
|
||||
admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表
|
||||
admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员
|
||||
admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色
|
||||
|
||||
@@ -386,3 +386,20 @@ func (p *Postgres) ResolveBillingTenantID(ctx context.Context, userID, activeTen
|
||||
}
|
||||
return activeTenantID
|
||||
}
|
||||
|
||||
// SetTenantPlan 更改租户方案等级。
|
||||
func (p *Postgres) SetTenantPlan(ctx context.Context, tenantID string, plan string) error {
|
||||
if p.db == nil {
|
||||
return errStoreDisabled
|
||||
}
|
||||
return p.db.WithContext(ctx).Model(&Tenant{}).Where("id = ?", tenantID).Update("plan", plan).Error
|
||||
}
|
||||
|
||||
// SetTenantStatus 更改租户状态(active / suspended)。
|
||||
func (p *Postgres) SetTenantStatus(ctx context.Context, tenantID string, status string) error {
|
||||
if p.db == nil {
|
||||
return errStoreDisabled
|
||||
}
|
||||
return p.db.WithContext(ctx).Model(&Tenant{}).Where("id = ?", tenantID).Update("status", status).Error
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user