Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
afc6952eda
172 files changed
+24591
No files matched your search
@@ -0,0 +1,519 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file exposes the "localstorage" (host local filesystem) plugin's settings
|
||||
// to end users through the same three-layer cascade OpenSky and filetransfer use
|
||||
// (superadmin/global → organization → user); see integrations.go for the shared
|
||||
// helpers (lockedFor, layerRank, maskPresent, callerFromRecord, boolStr).
|
||||
//
|
||||
// - global (L1): the plugin's config in plugins.json, set in the API Server panel
|
||||
// (the storage root basePath + the master switch + a global read-only default).
|
||||
// - org (L2): pluginSettings.localstorage on the caller's organization record.
|
||||
// - user (L3): pluginSettings.localstorage on the caller's own user record.
|
||||
//
|
||||
// Unlike filetransfer, a local drive gives each tenant *isolated* folders rather
|
||||
// than a freely-chosen path. Folders are derived from identity, never taken from a
|
||||
// client, and laid out so that "private" is genuinely private:
|
||||
//
|
||||
// <root>/orgs/<orgId>/shared — the organization folder (all members)
|
||||
// <root>/orgs/<orgId>/private/<userId>— a member's private folder (opt-in)
|
||||
// <root>/users/<userId> — an org-less user's private folder
|
||||
//
|
||||
// A member on the shared folder is confined to .../shared and cannot traverse into
|
||||
// anyone's .../private subtree; each private folder is confined to its own
|
||||
// .../private/<userId>. So an org member can hold BOTH the shared org folder and a
|
||||
// private folder nested inside the org folder, reachable only by them. The plugin
|
||||
// confines every operation within the folder it is handed, so isolation is enforced
|
||||
// end-to-end.
|
||||
//
|
||||
// A member opts into their private folder personally (user layer); an org admin may
|
||||
// gate the feature for the whole organization (org layer, default allowed). The one
|
||||
// other cascading tunable is readOnly (blank falls through, top wins, a set value
|
||||
// locks lower layers).
|
||||
|
||||
const localStoragePlugin = "localstorage"
|
||||
|
||||
// lsConfig is one layer's editable localstorage settings. Folders are not here:
|
||||
// they are computed from identity, never stored or taken from a client.
|
||||
type lsConfig struct {
|
||||
ReadOnly string `json:"readOnly"` // "" (inherit) | "true" | "false"
|
||||
}
|
||||
|
||||
// lsStored is what we persist per user/org under pluginSettings.localstorage.
|
||||
type lsStored struct {
|
||||
Config lsConfig `json:"config"`
|
||||
// Enabled is the personal per-user opt-in (user layer). Default false.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Disabled is the organization layer's off switch, stored inverted so that
|
||||
// absent == enabled (mirrors OpenSky). Only meaningful on the org record.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
// PrivateFolder is the user layer's opt-in for a private folder inside the org
|
||||
// folder. Only meaningful for a caller who belongs to an organization.
|
||||
PrivateFolder bool `json:"privateFolder,omitempty"`
|
||||
// DisallowPrivate is the org layer's gate on private folders, stored inverted so
|
||||
// that absent == allowed. Only meaningful on the org record.
|
||||
DisallowPrivate bool `json:"disallowPrivate,omitempty"`
|
||||
}
|
||||
|
||||
// lsSettingsDoc is the localstorage slice of the shared pluginSettings JSON.
|
||||
type lsSettingsDoc struct {
|
||||
LocalStorage lsStored `json:"localstorage"`
|
||||
}
|
||||
|
||||
// lsMount is one isolated folder a caller can reach.
|
||||
type lsMount struct {
|
||||
ID string `json:"id"` // "shared" | "private" | "personal"
|
||||
Label string `json:"label"` // human label for the UI
|
||||
Path string `json:"path"` // absolute folder path
|
||||
Kind string `json:"kind"` // "shared" | "private"
|
||||
}
|
||||
|
||||
// lsResolution is the fully-resolved localstorage state for one caller.
|
||||
type lsResolution struct {
|
||||
readOnly string // effective read-only ("" | "true" | "false")
|
||||
userReadOnly string // user (L3) read-only value
|
||||
orgReadOnly string // organization (L2) read-only value
|
||||
root string // global storage root
|
||||
mounts []lsMount // isolated folders in force for this caller
|
||||
source map[string]string // field -> layer name (global|org|user|unset)
|
||||
isSuper bool
|
||||
canOrg bool
|
||||
available bool // global master switch
|
||||
orgEnabled bool // org master switch (default true)
|
||||
enabled bool // personal opt-in to use the plugin
|
||||
isOrgUser bool // caller belongs to an organization
|
||||
allowPrivate bool // org policy: private folders permitted (default true)
|
||||
wantsPrivate bool // user's raw private-folder opt-in
|
||||
privateOn bool // effective: org user + allowed + opted in
|
||||
}
|
||||
|
||||
// Folder builders. Forward-slash joins (path, not filepath) since the target host
|
||||
// is Linux; the plugin re-resolves against the OS filesystem and confines within.
|
||||
func orgSharedFolder(root, orgID string) string {
|
||||
return path.Join(root, "orgs", orgID, "shared")
|
||||
}
|
||||
func orgPrivateFolder(root, orgID, userID string) string {
|
||||
return path.Join(root, "orgs", orgID, "private", userID)
|
||||
}
|
||||
func userPersonalFolder(root, userID string) string {
|
||||
return path.Join(root, "users", userID)
|
||||
}
|
||||
|
||||
// tenantMounts computes the isolated folders for a caller. An org member always
|
||||
// gets the shared org folder and, when privateOn, an additional private folder
|
||||
// nested inside the org folder; an org-less user gets a single private folder.
|
||||
func tenantMounts(root string, who *callerIdentity, privateOn bool) []lsMount {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return []lsMount{}
|
||||
}
|
||||
if who.OrgID != "" {
|
||||
mounts := []lsMount{{
|
||||
ID: "shared", Label: "Organization folder", Kind: "shared",
|
||||
Path: orgSharedFolder(root, who.OrgID),
|
||||
}}
|
||||
if privateOn && who.ID != "" {
|
||||
mounts = append(mounts, lsMount{
|
||||
ID: "private", Label: "Your private folder", Kind: "private",
|
||||
Path: orgPrivateFolder(root, who.OrgID, who.ID),
|
||||
})
|
||||
}
|
||||
return mounts
|
||||
}
|
||||
if who.ID != "" {
|
||||
return []lsMount{{
|
||||
ID: "personal", Label: "Your private folder", Kind: "private",
|
||||
Path: userPersonalFolder(root, who.ID),
|
||||
}}
|
||||
}
|
||||
return []lsMount{}
|
||||
}
|
||||
|
||||
// resolveLocalStorage computes the cascade for a caller. userRaw is the caller's
|
||||
// pluginSettings blob (from their auth-refresh record).
|
||||
func (s *Server) resolveLocalStorage(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) lsResolution {
|
||||
g, masterEnabled, _ := s.plugins.RawConfig(localStoragePlugin)
|
||||
root := strings.TrimSpace(g["basePath"])
|
||||
// The global panel's readOnly select defaults to a concrete "false"; treat that
|
||||
// as unset so the global default does not permanently lock lower layers. Only an
|
||||
// explicit global "true" freezes every folder.
|
||||
globalRO := strings.TrimSpace(g["readOnly"])
|
||||
if strings.EqualFold(globalRO, "false") {
|
||||
globalRO = ""
|
||||
}
|
||||
|
||||
var oStored lsStored
|
||||
if who.OrgID != "" {
|
||||
oStored, _ = s.orgLocalStorage(ctx, who.OrgID)
|
||||
}
|
||||
var uStored lsStored
|
||||
if len(userRaw) > 0 {
|
||||
var d lsSettingsDoc
|
||||
_ = json.Unmarshal(userRaw, &d)
|
||||
uStored = d.LocalStorage
|
||||
}
|
||||
|
||||
res := lsResolution{
|
||||
source: map[string]string{},
|
||||
userReadOnly: uStored.Config.ReadOnly,
|
||||
orgReadOnly: oStored.Config.ReadOnly,
|
||||
isSuper: who.isSuperadmin(),
|
||||
// An org admin may edit the organization layer in addition to their own.
|
||||
// Requires the service account (org writes go through it).
|
||||
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
|
||||
available: masterEnabled,
|
||||
orgEnabled: !oStored.Disabled,
|
||||
enabled: uStored.Enabled,
|
||||
root: root,
|
||||
isOrgUser: who.OrgID != "",
|
||||
allowPrivate: !oStored.DisallowPrivate, // default allowed
|
||||
wantsPrivate: uStored.PrivateFolder,
|
||||
}
|
||||
|
||||
// readOnly cascades top-wins, blanks fall through (same mechanism as OpenSky).
|
||||
type layer struct{ name, ro string }
|
||||
layers := []layer{{"global", globalRO}}
|
||||
if who.OrgID != "" {
|
||||
layers = append(layers, layer{"org", oStored.Config.ReadOnly})
|
||||
}
|
||||
layers = append(layers, layer{"user", uStored.Config.ReadOnly})
|
||||
roSrc := "unset"
|
||||
for _, l := range layers {
|
||||
if v := strings.TrimSpace(l.ro); v != "" {
|
||||
res.readOnly, roSrc = v, l.name
|
||||
break
|
||||
}
|
||||
}
|
||||
res.source["readOnly"] = roSrc
|
||||
|
||||
// Effective private-folder state and the isolated folders in force.
|
||||
res.privateOn = res.isOrgUser && res.allowPrivate && res.wantsPrivate
|
||||
res.mounts = tenantMounts(root, who, res.privateOn)
|
||||
return res
|
||||
}
|
||||
|
||||
// orgLocalStorage reads an organization's stored localstorage settings (config +
|
||||
// the org gate) and its raw pluginSettings blob via the service account. Best
|
||||
// effort: zero values on any miss so callers proceed as if the org layer were empty.
|
||||
func (s *Server) orgLocalStorage(ctx context.Context, orgID string) (lsStored, json.RawMessage) {
|
||||
if orgID == "" || !s.admin.configured() {
|
||||
return lsStored{}, nil
|
||||
}
|
||||
data, status, err := s.admin.do(ctx, http.MethodGet,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return lsStored{}, nil
|
||||
}
|
||||
var rec struct {
|
||||
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
var doc lsSettingsDoc
|
||||
if len(rec.PluginSettings) > 0 {
|
||||
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
||||
}
|
||||
return doc.LocalStorage, rec.PluginSettings
|
||||
}
|
||||
|
||||
// mergeLocalStorage applies a mutation to the localstorage entry of a
|
||||
// pluginSettings blob, preserving any other plugin keys, and returns the new blob.
|
||||
func mergeLocalStorage(existing json.RawMessage, apply func(*lsStored)) json.RawMessage {
|
||||
doc := map[string]json.RawMessage{}
|
||||
if len(existing) > 0 {
|
||||
_ = json.Unmarshal(existing, &doc)
|
||||
}
|
||||
if doc == nil {
|
||||
doc = map[string]json.RawMessage{} // existing was JSON null
|
||||
}
|
||||
var ls lsStored
|
||||
if raw, ok := doc["localstorage"]; ok {
|
||||
_ = json.Unmarshal(raw, &ls)
|
||||
}
|
||||
apply(&ls)
|
||||
b, _ := json.Marshal(ls)
|
||||
doc["localstorage"] = b
|
||||
out, _ := json.Marshal(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// GET /api/integrations/localstorage — resolved view for the caller.
|
||||
func (s *Server) handleGetLocalStorage(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveLocalStorage(r.Context(), who, userRaw)
|
||||
writeJSON(w, http.StatusOK, s.localStorageView(who, res))
|
||||
}
|
||||
|
||||
// lsScopeView builds the field set for one editable scope. editable is the layer
|
||||
// the caller edits ("user" | "org" | "none"); readOnly is locked when its effective
|
||||
// value is set above that layer.
|
||||
func (s *Server) lsScopeView(res lsResolution, editable string) map[string]any {
|
||||
own := res.userReadOnly
|
||||
if editable == "org" {
|
||||
own = res.orgReadOnly
|
||||
}
|
||||
src := res.source["readOnly"]
|
||||
field := osFieldView{
|
||||
Source: src,
|
||||
Locked: lockedFor(src, editable),
|
||||
Effective: res.readOnly,
|
||||
Own: own,
|
||||
}
|
||||
return map[string]any{
|
||||
"editableLayer": editable,
|
||||
"fields": map[string]osFieldView{"readOnly": field},
|
||||
}
|
||||
}
|
||||
|
||||
// localStorageView builds the client-safe response body. It exposes a "user" scope
|
||||
// for everyone plus, for org admins, an "org" scope. The effective isolated folders
|
||||
// are reported at the top level (derived, not editable).
|
||||
func (s *Server) localStorageView(who *callerIdentity, res lsResolution) map[string]any {
|
||||
out := map[string]any{
|
||||
"available": res.available,
|
||||
"orgEnabled": res.orgEnabled,
|
||||
"enabled": res.enabled,
|
||||
"role": who.Role,
|
||||
"orgId": who.OrgID,
|
||||
"canEditOrg": res.canOrg,
|
||||
"isSuperadmin": res.isSuper,
|
||||
"isOrgUser": res.isOrgUser,
|
||||
"rootConfigured": strings.TrimSpace(res.root) != "",
|
||||
"mounts": res.mounts,
|
||||
"privateFolder": res.wantsPrivate, // the user's own opt-in
|
||||
"privateEnabled": res.privateOn, // effective (may be gated off by the org)
|
||||
"allowPrivate": res.allowPrivate, // org policy
|
||||
}
|
||||
if res.isSuper {
|
||||
out["editableLayer"] = "none"
|
||||
out["scopes"] = map[string]any{"user": s.lsScopeView(res, "none")}
|
||||
return out
|
||||
}
|
||||
scopes := map[string]any{"user": s.lsScopeView(res, "user")}
|
||||
if res.canOrg {
|
||||
scopes["org"] = s.lsScopeView(res, "org")
|
||||
}
|
||||
out["scopes"] = scopes
|
||||
return out
|
||||
}
|
||||
|
||||
// PUT /api/integrations/localstorage — save the caller's editable layer. Body:
|
||||
// {enabled?, privateFolder?, allowPrivate?, scope?, config?}. Folders are derived,
|
||||
// so only readOnly, the enable flags, and the private-folder settings are writable.
|
||||
func (s *Server) handlePutLocalStorage(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
PrivateFolder *bool `json:"privateFolder"` // user: opt into a private folder
|
||||
AllowPrivate *bool `json:"allowPrivate"` // org: permit private folders
|
||||
Scope string `json:"scope"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveLocalStorage(r.Context(), who, userRaw)
|
||||
|
||||
// Resolve which layer this write targets.
|
||||
editable := "user"
|
||||
switch {
|
||||
case res.isSuper:
|
||||
editable = "none"
|
||||
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
|
||||
if !res.canOrg {
|
||||
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
|
||||
return
|
||||
}
|
||||
editable = "org"
|
||||
}
|
||||
|
||||
// Overlay readOnly onto the target scope's own value, unless it is locked above.
|
||||
newRO := res.userReadOnly
|
||||
if editable == "org" {
|
||||
newRO = res.orgReadOnly
|
||||
}
|
||||
if v, present := body.Config["readOnly"]; present && !lockedFor(res.source["readOnly"], editable) {
|
||||
newRO = normalizeReadOnly(v)
|
||||
}
|
||||
|
||||
// Persist the organization layer (admins) via the service account.
|
||||
if editable == "org" {
|
||||
if who.OrgID == "" {
|
||||
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
||||
return
|
||||
}
|
||||
if !s.admin.configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
|
||||
return
|
||||
}
|
||||
_, orgRaw := s.orgLocalStorage(r.Context(), who.OrgID)
|
||||
newDoc := mergeLocalStorage(orgRaw, func(ls *lsStored) {
|
||||
ls.Config.ReadOnly = newRO
|
||||
if body.Enabled != nil {
|
||||
ls.Disabled = !*body.Enabled // org master switch, stored inverted
|
||||
}
|
||||
if body.AllowPrivate != nil {
|
||||
ls.DisallowPrivate = !*body.AllowPrivate // stored inverted (absent = allowed)
|
||||
}
|
||||
})
|
||||
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
|
||||
map[string]json.RawMessage{"pluginSettings": newDoc})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if st != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save organization settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user record: the personal enable flag and private-folder opt-in
|
||||
// live here (user/superadmin scope), and so does the personal readOnly when this
|
||||
// write targets the user scope.
|
||||
personalEnable := body.Enabled != nil && editable != "org"
|
||||
personalPrivate := body.PrivateFolder != nil && editable != "org"
|
||||
if personalEnable || personalPrivate || editable == "user" {
|
||||
newDoc := mergeLocalStorage(userRaw, func(ls *lsStored) {
|
||||
if personalEnable {
|
||||
ls.Enabled = *body.Enabled
|
||||
}
|
||||
if personalPrivate {
|
||||
ls.PrivateFolder = *body.PrivateFolder
|
||||
}
|
||||
if editable == "user" {
|
||||
ls.Config.ReadOnly = newRO
|
||||
}
|
||||
})
|
||||
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
} else if code != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save user settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve and return the fresh view.
|
||||
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil || st != http.StatusOK || fresh == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
res2 := s.resolveLocalStorage(r.Context(), who, fresh.Record["pluginSettings"])
|
||||
writeJSON(w, http.StatusOK, s.localStorageView(who, res2))
|
||||
}
|
||||
|
||||
// normalizeReadOnly coerces a submitted readOnly value to the stored vocabulary.
|
||||
func normalizeReadOnly(v string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true":
|
||||
return "true"
|
||||
case "false":
|
||||
return "false"
|
||||
default:
|
||||
return "" // inherit
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/integrations/localstorage/health — live probe against every isolated
|
||||
// folder the caller holds (each auto-created), honouring the resolved read-only flag.
|
||||
func (s *Server) handleLocalStorageHealth(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !who.isSuperadmin() {
|
||||
if _, _, ok := s.plugins.RawConfig(localStoragePlugin); !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
}
|
||||
res := s.resolveLocalStorage(r.Context(), who, userRaw)
|
||||
if !res.available {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "Local storage is disabled by the administrator"}})
|
||||
return
|
||||
}
|
||||
if !res.orgEnabled {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "Local storage is disabled for your organization"}})
|
||||
return
|
||||
}
|
||||
if len(res.mounts) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "No storage root configured by the administrator"}})
|
||||
return
|
||||
}
|
||||
|
||||
// Probe each folder; aggregate to the worst status for the summary badge and
|
||||
// return per-folder results so the UI can annotate each mount.
|
||||
perMount := make([]map[string]any, 0, len(res.mounts))
|
||||
worst := "ok"
|
||||
for _, m := range res.mounts {
|
||||
cfg := map[string]string{
|
||||
"basePath": m.Path,
|
||||
"createMissing": "true", // each folder is provisioned on demand
|
||||
"readOnly": res.readOnly,
|
||||
}
|
||||
h, err := s.plugins.HealthCheckWith(r.Context(), localStoragePlugin, cfg)
|
||||
status, detail := "down", ""
|
||||
if err != nil {
|
||||
detail = err.Error()
|
||||
} else {
|
||||
status, detail = h.Status, h.Detail
|
||||
}
|
||||
worst = worseStatus(worst, status)
|
||||
perMount = append(perMount, map[string]any{
|
||||
"id": m.ID, "label": m.Label, "path": m.Path, "status": status, "detail": detail,
|
||||
})
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("%d folder%s reachable", len(res.mounts), plural2(len(res.mounts)))
|
||||
if worst != "ok" {
|
||||
// Surface the first non-ok detail so the badge is actionable.
|
||||
for _, m := range perMount {
|
||||
if m["status"] != "ok" {
|
||||
summary = fmt.Sprintf("%s: %v", m["label"], m["detail"])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"health": map[string]any{"status": worst, "detail": summary},
|
||||
"mounts": perMount,
|
||||
})
|
||||
}
|
||||
|
||||
// worseStatus returns the more severe of two health statuses (ok < degraded < down).
|
||||
func worseStatus(a, b string) string {
|
||||
rank := map[string]int{"ok": 0, "degraded": 1, "down": 2}
|
||||
if rank[b] > rank[a] {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func plural2(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
Reference in new issue
Block a user