1190 lines
36 KiB
Go
1190 lines
36 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type controlsRow struct {
|
|
ID int
|
|
Name string
|
|
Type string
|
|
Status string
|
|
Selected bool
|
|
ToggleURL string
|
|
EditURL string
|
|
RemoveURL string
|
|
}
|
|
|
|
type controlsPageData struct {
|
|
Title string
|
|
CurrentPath string
|
|
Rows []controlsRow
|
|
Segment string
|
|
Page int
|
|
Pager tableDemoPager
|
|
VisibleCount int
|
|
SelectedCount int
|
|
SelectedVisible int
|
|
SelectedHidden int
|
|
ActionMessage string
|
|
SegmentedCounts map[string]int
|
|
SegmentURLs map[string]string
|
|
SelectVisibleURL string
|
|
SelectFilteredURL string
|
|
ClearVisibleURL string
|
|
ClearFilteredURL string
|
|
ClearSelectionURL string
|
|
BulkReviewURL string
|
|
BulkArchiveURL string
|
|
BulkExportURL string
|
|
BulkRetrySyncURL string
|
|
OpenEditSelectedURL string
|
|
OpenDeleteSelectedURL string
|
|
HasSelection bool
|
|
SimulateLoading bool
|
|
SimulateLoadingURL string
|
|
ClearLoadingURL string
|
|
}
|
|
|
|
type modalDemoPageData struct {
|
|
Title string
|
|
CurrentPath string
|
|
Open string
|
|
Stage string
|
|
Message string
|
|
SelectedIDs []string
|
|
}
|
|
|
|
type ioImportPreviewRow struct {
|
|
RowNo int
|
|
ItemCode string
|
|
Name string
|
|
Qty int
|
|
Status string
|
|
}
|
|
|
|
type ioPageData struct {
|
|
Title string
|
|
CurrentPath string
|
|
ImportMode string
|
|
FileName string
|
|
ImportMessage string
|
|
PreviewRows []ioImportPreviewRow
|
|
ExportFormat string
|
|
ExportScope string
|
|
ExportMessage string
|
|
}
|
|
|
|
type formsDemoPageData struct {
|
|
Title string
|
|
CurrentPath string
|
|
Mode string
|
|
Step string
|
|
ServerSerial string
|
|
Location string
|
|
ComponentSerial string
|
|
EventDate string
|
|
Details string
|
|
Message string
|
|
FieldErrors map[string]string
|
|
LocationOptions []string
|
|
ServerOptions []string
|
|
ComponentOptions []string
|
|
StepURLs map[string]string
|
|
ModeURLs map[string]string
|
|
}
|
|
|
|
type stylePlaygroundPageData struct {
|
|
Title string
|
|
CurrentPath string
|
|
Style string
|
|
StyleLabel string
|
|
StyleURLs map[string]string
|
|
StyleClass string
|
|
LoadingDemo bool
|
|
LoadingDemoURL string
|
|
ClearLoadingURL string
|
|
}
|
|
|
|
type operatorToolJob struct {
|
|
ID string
|
|
Tool string
|
|
Scope string
|
|
Mode string
|
|
Status string
|
|
Owner string
|
|
StartedAt string
|
|
Selected bool
|
|
ToggleURL string
|
|
RetryURL string
|
|
CancelURL string
|
|
ExportURL string
|
|
InspectURL string
|
|
}
|
|
|
|
type operatorToolsPageData struct {
|
|
Title string
|
|
CurrentPath string
|
|
Scope string
|
|
Queue string
|
|
Rows []operatorToolJob
|
|
VisibleCount int
|
|
SelectedCount int
|
|
SelectedVisible int
|
|
SelectionOutside int
|
|
ActionMessage string
|
|
ScopeURLs map[string]string
|
|
QueueURLs map[string]string
|
|
SelectVisibleURL string
|
|
ClearVisibleURL string
|
|
ClearSelectionURL string
|
|
RunSelectedURL string
|
|
RetrySelectedURL string
|
|
CancelSelectedURL string
|
|
OpenReviewModalURL string
|
|
ImportPreviewURL string
|
|
ExportFilteredURL string
|
|
ExportSelectedURL string
|
|
SafetyChecklist []string
|
|
RecentActivityNotes []string
|
|
}
|
|
|
|
type timelineEvent struct {
|
|
ID string
|
|
At string
|
|
Action string
|
|
Source string
|
|
Entity string
|
|
Target string
|
|
Detail string
|
|
Slot string
|
|
Device string
|
|
}
|
|
|
|
type timelineCard struct {
|
|
ID string
|
|
Day string
|
|
Title string
|
|
Action string
|
|
Source string
|
|
Count int
|
|
SummaryLeft []string
|
|
SummaryRight []string
|
|
Items []timelineEvent
|
|
OpenURL string
|
|
Open bool
|
|
}
|
|
|
|
type timelinePageData struct {
|
|
Title string
|
|
CurrentPath string
|
|
ActionFilter string
|
|
SourceFilter string
|
|
ActionURLs map[string]string
|
|
SourceURLs map[string]string
|
|
Cards []timelineCard
|
|
OpenCard *timelineCard
|
|
CardSearch string
|
|
ClearCardURL string
|
|
ActiveEvent *timelineEvent
|
|
}
|
|
|
|
func (s *Server) handleControlsPattern(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/patterns/controls" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
segment := strings.TrimSpace(r.URL.Query().Get("segment"))
|
|
if segment == "" {
|
|
segment = "all"
|
|
}
|
|
page := parsePositiveInt(r.URL.Query().Get("page"), 1)
|
|
selected := parseIDSet(r.URL.Query()["sel"])
|
|
rows := demoControlsRows()
|
|
|
|
counts := map[string]int{"all": len(rows), "ready": 0, "warning": 0, "review": 0}
|
|
filteredAll := make([]controlsRow, 0, len(rows))
|
|
filteredIDs := make([]string, 0, len(rows))
|
|
for _, row := range rows {
|
|
counts[strings.ToLower(row.Status)]++
|
|
if segment != "all" && !strings.EqualFold(row.Status, segment) {
|
|
continue
|
|
}
|
|
filteredAll = append(filteredAll, row)
|
|
filteredIDs = append(filteredIDs, strconv.Itoa(row.ID))
|
|
}
|
|
|
|
pageRows, pager := paginateControlsRows(segment, selected, filteredAll, page, 5)
|
|
visibleIDs := make([]string, 0, len(pageRows))
|
|
for _, row := range pageRows {
|
|
visibleIDs = append(visibleIDs, strconv.Itoa(row.ID))
|
|
}
|
|
|
|
switch strings.TrimSpace(r.URL.Query().Get("selection_action")) {
|
|
case "select_visible":
|
|
for _, id := range visibleIDs {
|
|
selected[id] = true
|
|
}
|
|
case "clear_visible":
|
|
for _, id := range visibleIDs {
|
|
delete(selected, id)
|
|
}
|
|
case "select_filtered":
|
|
for _, id := range filteredIDs {
|
|
selected[id] = true
|
|
}
|
|
case "clear_filtered":
|
|
for _, id := range filteredIDs {
|
|
delete(selected, id)
|
|
}
|
|
case "clear_all":
|
|
selected = map[string]bool{}
|
|
case "toggle":
|
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
if id != "" {
|
|
if selected[id] {
|
|
delete(selected, id)
|
|
} else {
|
|
selected[id] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
selectedCSV := joinSelectedIDs(selected)
|
|
pageRows, pager = paginateControlsRows(segment, selected, filteredAll, page, 5)
|
|
visibleIDs = visibleIDs[:0]
|
|
for _, row := range pageRows {
|
|
visibleIDs = append(visibleIDs, strconv.Itoa(row.ID))
|
|
}
|
|
selectedVisible := 0
|
|
for i := range pageRows {
|
|
id := strconv.Itoa(pageRows[i].ID)
|
|
pageRows[i].Selected = selected[id]
|
|
if pageRows[i].Selected {
|
|
selectedVisible++
|
|
}
|
|
pageRows[i].ToggleURL = controlsURL(segment, pager.Page, selectedCSV, "toggle", id, "", nil)
|
|
pageRows[i].EditURL = modalURL("edit", "edit", selectedCSV, id)
|
|
pageRows[i].RemoveURL = modalURL("delete", "confirm", selectedCSV, id)
|
|
}
|
|
selectedHidden := len(selected) - selectedVisible
|
|
if selectedHidden < 0 {
|
|
selectedHidden = 0
|
|
}
|
|
|
|
action := strings.TrimSpace(r.URL.Query().Get("bulk"))
|
|
loading := r.URL.Query().Get("loading") == "1"
|
|
actionMsg := ""
|
|
if action != "" {
|
|
actionMsg = fmt.Sprintf("Bulk action preview: %s on %d selected item(s).", action, len(selected))
|
|
}
|
|
|
|
data := controlsPageData{
|
|
Title: "Controls + Selection Pattern",
|
|
CurrentPath: "/patterns/controls",
|
|
Rows: pageRows,
|
|
Segment: segment,
|
|
Page: pager.Page,
|
|
Pager: pager,
|
|
VisibleCount: len(pageRows),
|
|
SelectedCount: len(selected),
|
|
SelectedVisible: selectedVisible,
|
|
SelectedHidden: selectedHidden,
|
|
ActionMessage: actionMsg,
|
|
SegmentedCounts: counts,
|
|
SegmentURLs: controlsSegmentURLs(selectedCSV),
|
|
SelectVisibleURL: controlsURL(segment, pager.Page, selectedCSV, "select_visible", "", "", nil),
|
|
SelectFilteredURL: controlsURL(segment, pager.Page, selectedCSV, "select_filtered", "", "", nil),
|
|
ClearVisibleURL: controlsURL(segment, pager.Page, selectedCSV, "clear_visible", "", "", nil),
|
|
ClearFilteredURL: controlsURL(segment, pager.Page, selectedCSV, "clear_filtered", "", "", nil),
|
|
ClearSelectionURL: controlsURL(segment, pager.Page, "", "clear_all", "", "", nil),
|
|
BulkReviewURL: controlsURL(segment, pager.Page, selectedCSV, "", "", "review", nil),
|
|
BulkArchiveURL: controlsURL(segment, pager.Page, selectedCSV, "", "", "archive", nil),
|
|
BulkExportURL: controlsURL(segment, pager.Page, selectedCSV, "", "", "export", nil),
|
|
BulkRetrySyncURL: controlsURL(segment, pager.Page, selectedCSV, "", "", "retry_sync", nil),
|
|
OpenEditSelectedURL: modalURL("edit", "edit", selectedCSV, ""),
|
|
OpenDeleteSelectedURL: modalURL("delete", "confirm", selectedCSV, ""),
|
|
HasSelection: len(selected) > 0,
|
|
SimulateLoading: loading,
|
|
SimulateLoadingURL: controlsURL(segment, pager.Page, selectedCSV, "", "", "review", map[string]string{"loading": "1"}),
|
|
ClearLoadingURL: controlsURL(segment, pager.Page, selectedCSV, "", "", "", map[string]string{"loading": ""}),
|
|
}
|
|
s.renderTemplate(w, "controls_pattern.html", data)
|
|
}
|
|
|
|
func (s *Server) handleModalPattern(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/patterns/modals" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
open := strings.TrimSpace(r.URL.Query().Get("open"))
|
|
stage := strings.TrimSpace(r.URL.Query().Get("stage"))
|
|
if stage == "" {
|
|
stage = "edit"
|
|
}
|
|
selectedIDs := selectedIDSlice(r.URL.Query()["sel"])
|
|
msg := ""
|
|
switch stage {
|
|
case "confirm":
|
|
msg = "Confirm stage: summarize changes and require explicit confirmation."
|
|
case "done":
|
|
msg = "Completed state: show success summary and next actions."
|
|
default:
|
|
msg = "Edit stage: collect inputs and validate before transition to confirm."
|
|
}
|
|
data := modalDemoPageData{
|
|
Title: "Modal Workflows Pattern",
|
|
CurrentPath: "/patterns/modals",
|
|
Open: open,
|
|
Stage: stage,
|
|
Message: msg,
|
|
SelectedIDs: selectedIDs,
|
|
}
|
|
s.renderTemplate(w, "modal_pattern.html", data)
|
|
}
|
|
|
|
func (s *Server) handleIOPattern(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/patterns/io" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
mode := strings.TrimSpace(r.URL.Query().Get("import_mode"))
|
|
if mode == "" {
|
|
mode = "preview"
|
|
}
|
|
fileName := strings.TrimSpace(r.URL.Query().Get("file"))
|
|
if fileName == "" {
|
|
fileName = "items.csv"
|
|
}
|
|
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
|
|
if scope == "" {
|
|
scope = "filtered"
|
|
}
|
|
format := strings.TrimSpace(r.URL.Query().Get("format"))
|
|
if format == "" {
|
|
format = "csv"
|
|
}
|
|
preview := []ioImportPreviewRow{
|
|
{RowNo: 1, ItemCode: "CMP-001", Name: "Controller board", Qty: 2, Status: "ok"},
|
|
{RowNo: 2, ItemCode: "CMP-002", Name: "PSU module", Qty: 1, Status: "warning"},
|
|
{RowNo: 3, ItemCode: "CMP-003", Name: "Network adapter", Qty: 4, Status: "ok"},
|
|
{RowNo: 4, ItemCode: "CMP-004", Name: "Missing mapping sample", Qty: 1, Status: "error"},
|
|
}
|
|
msg := "Import workflow pattern: upload -> preview/validate -> confirm."
|
|
if mode == "confirm" {
|
|
msg = "Confirm import step: user reviews validation summary before submitting."
|
|
}
|
|
exportMsg := "Export workflow pattern: explicit format/scope selection and predictable filename."
|
|
if r.URL.Query().Get("export_ready") == "1" {
|
|
exportMsg = "Export is ready. Use the download action below (real CSV endpoint in demo)."
|
|
}
|
|
data := ioPageData{
|
|
Title: "Import / Export Pattern",
|
|
CurrentPath: "/patterns/io",
|
|
ImportMode: mode,
|
|
FileName: fileName,
|
|
ImportMessage: msg,
|
|
PreviewRows: preview,
|
|
ExportFormat: format,
|
|
ExportScope: scope,
|
|
ExportMessage: exportMsg,
|
|
}
|
|
s.renderTemplate(w, "io_pattern.html", data)
|
|
}
|
|
|
|
func (s *Server) handleFormsPattern(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/patterns/forms" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
|
if mode == "" {
|
|
mode = "register"
|
|
}
|
|
step := strings.TrimSpace(r.URL.Query().Get("step"))
|
|
if step == "" {
|
|
step = "edit"
|
|
}
|
|
data := formsDemoPageData{
|
|
Title: "Forms + Validation Pattern",
|
|
CurrentPath: "/patterns/forms",
|
|
Mode: mode,
|
|
Step: step,
|
|
ServerSerial: strings.TrimSpace(r.URL.Query().Get("server_serial")),
|
|
Location: strings.TrimSpace(r.URL.Query().Get("location")),
|
|
ComponentSerial: strings.TrimSpace(r.URL.Query().Get("component_serial")),
|
|
EventDate: strings.TrimSpace(r.URL.Query().Get("event_date")),
|
|
Details: strings.TrimSpace(r.URL.Query().Get("details")),
|
|
FieldErrors: map[string]string{},
|
|
LocationOptions: []string{"AOC#1", "AOC#2", "PSU#1", "PSU#2", "CTRL#1"},
|
|
ServerOptions: []string{"SRV-001", "SRV-002", "SRV-003", "SRV-010"},
|
|
ComponentOptions: []string{
|
|
"NIC-AX210-001", "NIC-AX210-002", "PSU-750W-100", "CTRL-MGMT-014",
|
|
},
|
|
StepURLs: map[string]string{
|
|
"edit": formsURL(mode, "edit", url.Values{}),
|
|
"review": formsURL(mode, "review", carryFormFields(r.URL.Query())),
|
|
"confirm": formsURL(mode, "confirm", carryFormFields(r.URL.Query())),
|
|
},
|
|
ModeURLs: map[string]string{
|
|
"register": formsURL("register", "edit", carryFormFields(r.URL.Query())),
|
|
"import": formsURL("import", "edit", carryFormFields(r.URL.Query())),
|
|
},
|
|
}
|
|
if data.EventDate == "" {
|
|
data.EventDate = "2026-02-23"
|
|
}
|
|
|
|
if step == "review" || step == "confirm" {
|
|
validateFormsDemo(&data)
|
|
if len(data.FieldErrors) > 0 && step != "edit" {
|
|
data.Message = "Validation errors must be resolved before confirmation."
|
|
}
|
|
}
|
|
if step == "edit" {
|
|
data.Message = "Edit step: enter values, use suggestions, then move to review."
|
|
} else if step == "review" {
|
|
if len(data.FieldErrors) == 0 {
|
|
data.Message = "Review step: summarize recognized values and request explicit confirmation."
|
|
}
|
|
} else if step == "confirm" {
|
|
if len(data.FieldErrors) == 0 {
|
|
data.Message = "Done state: show human-readable result and next actions."
|
|
}
|
|
}
|
|
s.renderTemplate(w, "forms_pattern.html", data)
|
|
}
|
|
|
|
func (s *Server) handleStylePlaygroundPattern(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/patterns/style-playground" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
style := strings.TrimSpace(r.URL.Query().Get("style"))
|
|
if style == "" {
|
|
style = "aqua"
|
|
}
|
|
if style == "vaporwave" {
|
|
style = "vaporwave-soft"
|
|
}
|
|
allowed := map[string]string{
|
|
"linen": "Linen / Editorial",
|
|
"slate": "Slate / Utility",
|
|
"signal": "Signal / Accent",
|
|
"y2k-silver": "Y2K / Silver Chrome",
|
|
"vaporwave-soft": "Vaporwave / Soft Day",
|
|
"vaporwave-night": "Vaporwave / Night",
|
|
"aqua": "macOS Aqua",
|
|
"win9x": "Windows 95-2000",
|
|
}
|
|
label, ok := allowed[style]
|
|
if !ok {
|
|
style = "aqua"
|
|
label = allowed[style]
|
|
}
|
|
styleClass := "theme-" + style
|
|
if style == "vaporwave-soft" {
|
|
// Keep CSS compatibility with the first vaporwave implementation.
|
|
styleClass = "theme-vaporwave"
|
|
}
|
|
data := stylePlaygroundPageData{
|
|
Title: "Style Playground",
|
|
CurrentPath: "/patterns/style-playground",
|
|
Style: style,
|
|
StyleLabel: label,
|
|
StyleClass: styleClass,
|
|
StyleURLs: map[string]string{
|
|
"linen": anchored("/patterns/style-playground?style=linen", "style-presets"),
|
|
"slate": anchored("/patterns/style-playground?style=slate", "style-presets"),
|
|
"signal": anchored("/patterns/style-playground?style=signal", "style-presets"),
|
|
"y2k-silver": anchored("/patterns/style-playground?style=y2k-silver", "style-presets"),
|
|
"vaporwave-soft": anchored("/patterns/style-playground?style=vaporwave-soft", "style-presets"),
|
|
"vaporwave-night": anchored("/patterns/style-playground?style=vaporwave-night", "style-presets"),
|
|
"aqua": anchored("/patterns/style-playground?style=aqua", "style-presets"),
|
|
"win9x": anchored("/patterns/style-playground?style=win9x", "style-presets"),
|
|
},
|
|
LoadingDemo: r.URL.Query().Get("loading") == "1",
|
|
LoadingDemoURL: anchored("/patterns/style-playground?style="+style+"&loading=1", "style-components"),
|
|
ClearLoadingURL: anchored("/patterns/style-playground?style="+style, "style-components"),
|
|
}
|
|
s.renderTemplate(w, "style_playground_pattern.html", data)
|
|
}
|
|
|
|
func (s *Server) handleOperatorToolsPattern(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/patterns/operator-tools" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
|
|
if scope == "" {
|
|
scope = "assets"
|
|
}
|
|
queue := strings.TrimSpace(r.URL.Query().Get("queue"))
|
|
if queue == "" {
|
|
queue = "all"
|
|
}
|
|
selected := parseIDSet(r.URL.Query()["sel"])
|
|
|
|
all := demoOperatorToolJobs()
|
|
filtered := make([]operatorToolJob, 0, len(all))
|
|
visibleIDs := make([]string, 0, len(all))
|
|
for _, row := range all {
|
|
if scope != "all" && !strings.EqualFold(row.Scope, scope) {
|
|
continue
|
|
}
|
|
if queue != "all" && !strings.EqualFold(row.Status, queue) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, row)
|
|
visibleIDs = append(visibleIDs, row.ID)
|
|
}
|
|
|
|
switch strings.TrimSpace(r.URL.Query().Get("selection_action")) {
|
|
case "select_visible":
|
|
for _, id := range visibleIDs {
|
|
selected[id] = true
|
|
}
|
|
case "clear_visible":
|
|
for _, id := range visibleIDs {
|
|
delete(selected, id)
|
|
}
|
|
case "clear_all":
|
|
selected = map[string]bool{}
|
|
case "toggle":
|
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
if id != "" {
|
|
if selected[id] {
|
|
delete(selected, id)
|
|
} else {
|
|
selected[id] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
selCSV := joinSelectedIDs(selected)
|
|
selectedVisible := 0
|
|
for i := range filtered {
|
|
filtered[i].Selected = selected[filtered[i].ID]
|
|
if filtered[i].Selected {
|
|
selectedVisible++
|
|
}
|
|
filtered[i].ToggleURL = operatorToolsURL(scope, queue, selCSV, "toggle", filtered[i].ID, "")
|
|
filtered[i].RetryURL = operatorToolsURL(scope, queue, selCSV, "", "", "retry")
|
|
filtered[i].CancelURL = operatorToolsURL(scope, queue, selCSV, "", "", "cancel")
|
|
filtered[i].ExportURL = anchored("/patterns/io?scope=filtered&export_ready=1", "io-export")
|
|
filtered[i].InspectURL = timelineURL("", "", "c1", "", "")
|
|
}
|
|
selectedHidden := len(selected) - selectedVisible
|
|
if selectedHidden < 0 {
|
|
selectedHidden = 0
|
|
}
|
|
|
|
action := strings.TrimSpace(r.URL.Query().Get("batch"))
|
|
actionMessage := ""
|
|
if action != "" {
|
|
actionMessage = fmt.Sprintf("Operator batch preview: %s on %d selected job(s).", action, len(selected))
|
|
}
|
|
if len(selected) == 0 && action != "" {
|
|
actionMessage = "Operator batch preview requires explicit selection first."
|
|
}
|
|
|
|
data := operatorToolsPageData{
|
|
Title: "Operator Tools Pattern",
|
|
CurrentPath: "/patterns/operator-tools",
|
|
Scope: scope,
|
|
Queue: queue,
|
|
Rows: filtered,
|
|
VisibleCount: len(filtered),
|
|
SelectedCount: len(selected),
|
|
SelectedVisible: selectedVisible,
|
|
SelectionOutside: selectedHidden,
|
|
ActionMessage: actionMessage,
|
|
ScopeURLs: map[string]string{
|
|
"assets": operatorToolsURL("assets", queue, selCSV, "", "", ""),
|
|
"components": operatorToolsURL("components", queue, selCSV, "", "", ""),
|
|
"imports": operatorToolsURL("imports", queue, selCSV, "", "", ""),
|
|
"maintenance": operatorToolsURL("maintenance", queue, selCSV, "", "", ""),
|
|
"all": operatorToolsURL("all", queue, selCSV, "", "", ""),
|
|
},
|
|
QueueURLs: map[string]string{
|
|
"all": operatorToolsURL(scope, "all", selCSV, "", "", ""),
|
|
"queued": operatorToolsURL(scope, "queued", selCSV, "", "", ""),
|
|
"running": operatorToolsURL(scope, "running", selCSV, "", "", ""),
|
|
"failed": operatorToolsURL(scope, "failed", selCSV, "", "", ""),
|
|
"done": operatorToolsURL(scope, "done", selCSV, "", "", ""),
|
|
},
|
|
SelectVisibleURL: operatorToolsURL(scope, queue, selCSV, "select_visible", "", ""),
|
|
ClearVisibleURL: operatorToolsURL(scope, queue, selCSV, "clear_visible", "", ""),
|
|
ClearSelectionURL: operatorToolsURL(scope, queue, "", "clear_all", "", ""),
|
|
RunSelectedURL: operatorToolsURL(scope, queue, selCSV, "", "", "run"),
|
|
RetrySelectedURL: operatorToolsURL(scope, queue, selCSV, "", "", "retry"),
|
|
CancelSelectedURL: operatorToolsURL(scope, queue, selCSV, "", "", "cancel"),
|
|
OpenReviewModalURL: modalURL("edit", "confirm", selCSV, ""),
|
|
ImportPreviewURL: anchored("/patterns/io?import_mode=preview&file=operator-batch.csv", "io-import"),
|
|
ExportFilteredURL: anchored("/patterns/io?scope=filtered&format=csv&export_ready=1", "io-export"),
|
|
ExportSelectedURL: anchored("/patterns/io?scope=selected&format=csv&export_ready=1", "io-export"),
|
|
SafetyChecklist: []string{
|
|
"Require explicit selection for batch actions (never infer from hidden defaults).",
|
|
"Show scope and queue filters in the action bar before executing.",
|
|
"Destructive or high-impact operations must route through a confirm step.",
|
|
"Batch result summaries must be exportable and human-readable.",
|
|
},
|
|
RecentActivityNotes: []string{
|
|
"Failed runs should stay filterable and retryable without losing scope context.",
|
|
"Import and export affordances belong near batch controls, not hidden in settings.",
|
|
"Queue status labels must be stable and reused across table rows and summaries.",
|
|
},
|
|
}
|
|
s.renderTemplate(w, "operator_tools_pattern.html", data)
|
|
}
|
|
|
|
func (s *Server) handleIOExportCSV(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/patterns/io/export.csv" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
scope := r.URL.Query().Get("scope")
|
|
if scope == "" {
|
|
scope = "filtered"
|
|
}
|
|
filename := "2026-02-23 (DEMO) items.csv"
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
|
|
var buf bytes.Buffer
|
|
cw := csv.NewWriter(&buf)
|
|
cw.Comma = ';'
|
|
_ = cw.Write([]string{"Code", "Name", "Category", "Status", "Qty"})
|
|
for _, row := range demoControlsRows() {
|
|
if scope == "selected" && row.ID%2 == 0 {
|
|
continue
|
|
}
|
|
_ = cw.Write([]string{
|
|
fmt.Sprintf("CMP-%03d", row.ID),
|
|
row.Name,
|
|
row.Type,
|
|
row.Status,
|
|
strconv.Itoa((row.ID % 5) + 1),
|
|
})
|
|
}
|
|
cw.Flush()
|
|
_, _ = w.Write(buf.Bytes())
|
|
}
|
|
|
|
func (s *Server) handleTimelinePattern(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/patterns/timeline" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
actionFilter := strings.TrimSpace(r.URL.Query().Get("action"))
|
|
sourceFilter := strings.TrimSpace(r.URL.Query().Get("source"))
|
|
openID := strings.TrimSpace(r.URL.Query().Get("open"))
|
|
cardSearch := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
activeEventID := strings.TrimSpace(r.URL.Query().Get("event"))
|
|
|
|
all := demoTimelineCards()
|
|
cards := make([]timelineCard, 0, len(all))
|
|
for _, c := range all {
|
|
if actionFilter != "" && !strings.EqualFold(c.Action, actionFilter) {
|
|
continue
|
|
}
|
|
if sourceFilter != "" && !strings.EqualFold(c.Source, sourceFilter) {
|
|
continue
|
|
}
|
|
c.OpenURL = timelineURL(actionFilter, sourceFilter, c.ID, "", "")
|
|
c.Open = c.ID == openID
|
|
if c.Open && cardSearch != "" {
|
|
needle := strings.ToLower(cardSearch)
|
|
items := make([]timelineEvent, 0, len(c.Items))
|
|
for _, it := range c.Items {
|
|
h := strings.ToLower(strings.Join([]string{it.Action, it.Source, it.Entity, it.Target, it.Slot, it.Device, it.Detail}, " "))
|
|
if strings.Contains(h, needle) {
|
|
items = append(items, it)
|
|
}
|
|
}
|
|
c.Items = items
|
|
c.Count = len(items)
|
|
}
|
|
cards = append(cards, c)
|
|
}
|
|
|
|
var openCard *timelineCard
|
|
var activeEvent *timelineEvent
|
|
for i := range cards {
|
|
if cards[i].ID != openID {
|
|
continue
|
|
}
|
|
openCard = &cards[i]
|
|
if len(cards[i].Items) == 0 {
|
|
break
|
|
}
|
|
idx := 0
|
|
if activeEventID != "" {
|
|
for j := range cards[i].Items {
|
|
if cards[i].Items[j].ID == activeEventID {
|
|
idx = j
|
|
break
|
|
}
|
|
}
|
|
}
|
|
activeEvent = &cards[i].Items[idx]
|
|
break
|
|
}
|
|
|
|
data := timelinePageData{
|
|
Title: "Timeline Cards Pattern",
|
|
CurrentPath: "/patterns/timeline",
|
|
ActionFilter: actionFilter,
|
|
SourceFilter: sourceFilter,
|
|
ActionURLs: map[string]string{
|
|
"": timelineURL("", sourceFilter, "", "", ""),
|
|
"installation": timelineURL("installation", sourceFilter, "", "", ""),
|
|
"removal": timelineURL("removal", sourceFilter, "", "", ""),
|
|
"edit": timelineURL("edit", sourceFilter, "", "", ""),
|
|
},
|
|
SourceURLs: map[string]string{
|
|
"": timelineURL(actionFilter, "", "", "", ""),
|
|
"manual": timelineURL(actionFilter, "manual", "", "", ""),
|
|
"ingest": timelineURL(actionFilter, "ingest", "", "", ""),
|
|
"system": timelineURL(actionFilter, "system", "", "", ""),
|
|
},
|
|
Cards: cards,
|
|
OpenCard: openCard,
|
|
CardSearch: cardSearch,
|
|
ClearCardURL: timelineURL(actionFilter, sourceFilter, "", "", ""),
|
|
ActiveEvent: activeEvent,
|
|
}
|
|
s.renderTemplate(w, "timeline_pattern.html", data)
|
|
}
|
|
|
|
func validateFormsDemo(d *formsDemoPageData) {
|
|
if d.ServerSerial == "" && d.Mode == "register" {
|
|
d.FieldErrors["server_serial"] = "Server serial is required in register mode."
|
|
}
|
|
if d.ComponentSerial == "" {
|
|
d.FieldErrors["component_serial"] = "Component serial is required."
|
|
}
|
|
if d.EventDate == "" {
|
|
d.FieldErrors["event_date"] = "Date is required."
|
|
}
|
|
if d.Location == "" && d.Mode == "register" {
|
|
d.FieldErrors["location"] = "Location/slot is required."
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderTemplate(w http.ResponseWriter, name string, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func parseIDSet(values []string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, v := range values {
|
|
for _, p := range strings.Split(v, ",") {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
out[p] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func selectedIDSlice(values []string) []string {
|
|
set := parseIDSet(values)
|
|
keys := make([]string, 0, len(set))
|
|
for k := range set {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool {
|
|
ai, aerr := strconv.Atoi(keys[i])
|
|
aj, berr := strconv.Atoi(keys[j])
|
|
if aerr == nil && berr == nil {
|
|
return ai < aj
|
|
}
|
|
return keys[i] < keys[j]
|
|
})
|
|
return keys
|
|
}
|
|
|
|
func joinSelectedIDs(set map[string]bool) string {
|
|
if len(set) == 0 {
|
|
return ""
|
|
}
|
|
keys := make([]string, 0, len(set))
|
|
for k := range set {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool {
|
|
ai, aerr := strconv.Atoi(keys[i])
|
|
aj, berr := strconv.Atoi(keys[j])
|
|
if aerr == nil && berr == nil {
|
|
return ai < aj
|
|
}
|
|
return keys[i] < keys[j]
|
|
})
|
|
return strings.Join(keys, ",")
|
|
}
|
|
|
|
func demoControlsRows() []controlsRow {
|
|
names := []string{
|
|
"Controller board", "Power module", "NIC adapter", "Drive tray", "Cooling fan", "BMC board",
|
|
"CPU module", "Memory kit", "Rail set", "Backplane", "Patch panel", "Optics cage",
|
|
}
|
|
types := []string{"Compute", "Power", "Networking", "Storage"}
|
|
statuses := []string{"ready", "warning", "review"}
|
|
rows := make([]controlsRow, 0, len(names))
|
|
for i, n := range names {
|
|
rows = append(rows, controlsRow{
|
|
ID: i + 1,
|
|
Name: n,
|
|
Type: types[i%len(types)],
|
|
Status: statuses[i%len(statuses)],
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func controlsSegmentURLs(selCSV string) map[string]string {
|
|
return map[string]string{
|
|
"all": controlsURL("all", 1, selCSV, "", "", "", nil),
|
|
"ready": controlsURL("ready", 1, selCSV, "", "", "", nil),
|
|
"warning": controlsURL("warning", 1, selCSV, "", "", "", nil),
|
|
"review": controlsURL("review", 1, selCSV, "", "", "", nil),
|
|
}
|
|
}
|
|
|
|
func carryFormFields(q url.Values) url.Values {
|
|
out := url.Values{}
|
|
for _, k := range []string{"server_serial", "location", "component_serial", "event_date", "details"} {
|
|
if v := strings.TrimSpace(q.Get(k)); v != "" {
|
|
out.Set(k, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func formsURL(mode, step string, extra url.Values) string {
|
|
q := url.Values{}
|
|
if mode != "" {
|
|
q.Set("mode", mode)
|
|
}
|
|
if step != "" {
|
|
q.Set("step", step)
|
|
}
|
|
for k, vals := range extra {
|
|
for _, v := range vals {
|
|
q.Add(k, v)
|
|
}
|
|
}
|
|
if qs := q.Encode(); qs != "" {
|
|
return anchored("/patterns/forms?"+qs, "forms-contract")
|
|
}
|
|
return anchored("/patterns/forms", "forms-contract")
|
|
}
|
|
|
|
func paginateControlsRows(segment string, selected map[string]bool, rows []controlsRow, page, perPage int) ([]controlsRow, tableDemoPager) {
|
|
totalItems := len(rows)
|
|
totalPages := 1
|
|
if totalItems > 0 {
|
|
totalPages = (totalItems + perPage - 1) / perPage
|
|
}
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if page > totalPages {
|
|
page = totalPages
|
|
}
|
|
start := 0
|
|
end := 0
|
|
from := 0
|
|
to := 0
|
|
pageRows := []controlsRow{}
|
|
if totalItems > 0 {
|
|
start = (page - 1) * perPage
|
|
end = start + perPage
|
|
if end > totalItems {
|
|
end = totalItems
|
|
}
|
|
from = start + 1
|
|
to = end
|
|
pageRows = append(pageRows, rows[start:end]...)
|
|
}
|
|
selCSV := joinSelectedIDs(selected)
|
|
links := buildControlsPageLinks(segment, selCSV, page, totalPages)
|
|
pager := tableDemoPager{
|
|
Page: page,
|
|
PerPage: perPage,
|
|
TotalItems: totalItems,
|
|
TotalPages: totalPages,
|
|
From: from,
|
|
To: to,
|
|
Links: links,
|
|
}
|
|
if page > 1 {
|
|
pager.PrevURL = controlsURL(segment, page-1, selCSV, "", "", "", nil)
|
|
}
|
|
if page < totalPages {
|
|
pager.NextURL = controlsURL(segment, page+1, selCSV, "", "", "", nil)
|
|
}
|
|
return pageRows, pager
|
|
}
|
|
|
|
func buildControlsPageLinks(segment, selCSV string, current, totalPages int) []tableDemoPageLink {
|
|
if totalPages <= 1 {
|
|
return nil
|
|
}
|
|
if totalPages <= 4 {
|
|
out := make([]tableDemoPageLink, 0, totalPages)
|
|
for p := 1; p <= totalPages; p++ {
|
|
out = append(out, tableDemoPageLink{
|
|
Label: strconv.Itoa(p),
|
|
URL: controlsURL(segment, p, selCSV, "", "", "", nil),
|
|
Current: p == current,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
pages := map[int]bool{
|
|
1: true, 2: true,
|
|
totalPages - 1: true, totalPages: true,
|
|
current - 1: true, current: true, current + 1: true,
|
|
}
|
|
keys := make([]int, 0, len(pages))
|
|
for p := range pages {
|
|
if p < 1 || p > totalPages {
|
|
continue
|
|
}
|
|
keys = append(keys, p)
|
|
}
|
|
sort.Ints(keys)
|
|
out := make([]tableDemoPageLink, 0, len(keys)+2)
|
|
prev := 0
|
|
for _, p := range keys {
|
|
if prev != 0 && p-prev > 1 {
|
|
out = append(out, tableDemoPageLink{Label: "...", Ellipsis: true})
|
|
}
|
|
out = append(out, tableDemoPageLink{
|
|
Label: strconv.Itoa(p),
|
|
URL: controlsURL(segment, p, selCSV, "", "", "", nil),
|
|
Current: p == current,
|
|
})
|
|
prev = p
|
|
}
|
|
return out
|
|
}
|
|
|
|
func controlsURL(segment string, page int, selCSV, selectionAction, id, bulk string, extra map[string]string) string {
|
|
q := url.Values{}
|
|
if segment != "" {
|
|
q.Set("segment", segment)
|
|
}
|
|
if page > 1 {
|
|
q.Set("page", strconv.Itoa(page))
|
|
}
|
|
if selCSV != "" {
|
|
q.Set("sel", selCSV)
|
|
}
|
|
if selectionAction != "" {
|
|
q.Set("selection_action", selectionAction)
|
|
}
|
|
if id != "" {
|
|
q.Set("id", id)
|
|
}
|
|
if bulk != "" {
|
|
q.Set("bulk", bulk)
|
|
}
|
|
for k, v := range extra {
|
|
if v == "" {
|
|
q.Del(k)
|
|
continue
|
|
}
|
|
q.Set(k, v)
|
|
}
|
|
if qs := q.Encode(); qs != "" {
|
|
return anchored("/patterns/controls?"+qs, "controls-selection")
|
|
}
|
|
return anchored("/patterns/controls", "controls-selection")
|
|
}
|
|
|
|
func operatorToolsURL(scope, queue, selCSV, selectionAction, id, batch string) string {
|
|
q := url.Values{}
|
|
if scope != "" {
|
|
q.Set("scope", scope)
|
|
}
|
|
if queue != "" {
|
|
q.Set("queue", queue)
|
|
}
|
|
if selCSV != "" {
|
|
q.Set("sel", selCSV)
|
|
}
|
|
if selectionAction != "" {
|
|
q.Set("selection_action", selectionAction)
|
|
}
|
|
if id != "" {
|
|
q.Set("id", id)
|
|
}
|
|
if batch != "" {
|
|
q.Set("batch", batch)
|
|
}
|
|
if qs := q.Encode(); qs != "" {
|
|
return anchored("/patterns/operator-tools?"+qs, "operator-queue")
|
|
}
|
|
return anchored("/patterns/operator-tools", "operator-queue")
|
|
}
|
|
|
|
func modalURL(open, stage, selCSV, singleID string) string {
|
|
q := url.Values{}
|
|
if open != "" {
|
|
q.Set("open", open)
|
|
}
|
|
if stage != "" {
|
|
q.Set("stage", stage)
|
|
}
|
|
if singleID != "" {
|
|
q.Set("sel", singleID)
|
|
} else if selCSV != "" {
|
|
q.Set("sel", selCSV)
|
|
}
|
|
if qs := q.Encode(); qs != "" {
|
|
return anchored("/patterns/modals?"+qs, "modal-open-states")
|
|
}
|
|
return anchored("/patterns/modals", "modal-open-states")
|
|
}
|
|
|
|
func timelineURL(action, source, open, qtext, event string) string {
|
|
q := url.Values{}
|
|
if action != "" {
|
|
q.Set("action", action)
|
|
}
|
|
if source != "" {
|
|
q.Set("source", source)
|
|
}
|
|
if open != "" {
|
|
q.Set("open", open)
|
|
}
|
|
if qtext != "" {
|
|
q.Set("q", qtext)
|
|
}
|
|
if event != "" {
|
|
q.Set("event", event)
|
|
}
|
|
fragment := "timeline-filters"
|
|
if open != "" {
|
|
fragment = "timeline-drilldown"
|
|
}
|
|
if qs := q.Encode(); qs != "" {
|
|
return anchored("/patterns/timeline?"+qs, fragment)
|
|
}
|
|
return anchored("/patterns/timeline", fragment)
|
|
}
|
|
|
|
func anchored(path, fragment string) string {
|
|
if fragment == "" {
|
|
return path
|
|
}
|
|
if strings.Contains(path, "#") {
|
|
return path
|
|
}
|
|
return path + "#" + fragment
|
|
}
|
|
|
|
func demoTimelineCards() []timelineCard {
|
|
return []timelineCard{
|
|
{
|
|
ID: "c1",
|
|
Day: "2026-02-23",
|
|
Title: "Installed 3 components",
|
|
Action: "installation",
|
|
Source: "manual",
|
|
Count: 3,
|
|
SummaryLeft: []string{"2x Network adapter", "1x Controller board"},
|
|
SummaryRight: []string{"AOC#1", "AOC#2", "CTRL#1"},
|
|
Items: []timelineEvent{
|
|
{ID: "e1", At: "10:12", Action: "installation", Source: "manual", Entity: "Asset A-12", Target: "Network adapter", Slot: "AOC#1", Device: "NIC", Detail: "Installed after maintenance"},
|
|
{ID: "e2", At: "10:13", Action: "installation", Source: "manual", Entity: "Asset A-12", Target: "Network adapter", Slot: "AOC#2", Device: "NIC", Detail: "Installed after maintenance"},
|
|
{ID: "e3", At: "10:16", Action: "installation", Source: "manual", Entity: "Asset A-12", Target: "Controller board", Slot: "CTRL#1", Device: "Controller", Detail: "Replacement unit attached"},
|
|
},
|
|
},
|
|
{
|
|
ID: "c2",
|
|
Day: "2026-02-23",
|
|
Title: "Removed 2 components",
|
|
Action: "removal",
|
|
Source: "ingest",
|
|
Count: 2,
|
|
SummaryLeft: []string{"1x PSU module", "1x Cooling fan"},
|
|
SummaryRight: []string{"PSU#2", "FAN#4"},
|
|
Items: []timelineEvent{
|
|
{ID: "e4", At: "08:42", Action: "removal", Source: "ingest", Entity: "Asset B-07", Target: "PSU module", Slot: "PSU#2", Device: "Power", Detail: "Absent in latest snapshot"},
|
|
{ID: "e5", At: "08:42", Action: "removal", Source: "ingest", Entity: "Asset B-07", Target: "Cooling fan", Slot: "FAN#4", Device: "Cooling", Detail: "Absent in latest snapshot"},
|
|
},
|
|
},
|
|
{
|
|
ID: "c3",
|
|
Day: "2026-02-22",
|
|
Title: "Bulk metadata edit",
|
|
Action: "edit",
|
|
Source: "system",
|
|
Count: 4,
|
|
SummaryLeft: []string{"Vendor normalized", "Status recomputed"},
|
|
SummaryRight: []string{"4 affected rows"},
|
|
Items: []timelineEvent{
|
|
{ID: "e6", At: "21:11", Action: "edit", Source: "system", Entity: "Asset C-03", Target: "Component metadata", Slot: "AOC#3", Device: "NIC", Detail: "Vendor name normalized"},
|
|
{ID: "e7", At: "21:11", Action: "edit", Source: "system", Entity: "Asset C-03", Target: "Component metadata", Slot: "AOC#4", Device: "NIC", Detail: "Vendor name normalized"},
|
|
{ID: "e8", At: "21:12", Action: "edit", Source: "system", Entity: "Asset C-03", Target: "Status", Slot: "PSU#1", Device: "Power", Detail: "Status recalculated from observations"},
|
|
{ID: "e9", At: "21:12", Action: "edit", Source: "system", Entity: "Asset C-03", Target: "Status", Slot: "PSU#2", Device: "Power", Detail: "Status recalculated from observations"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func demoOperatorToolJobs() []operatorToolJob {
|
|
return []operatorToolJob{
|
|
{ID: "job-101", Tool: "Recompute status", Scope: "assets", Mode: "dry-run", Status: "queued", Owner: "operator", StartedAt: "2026-02-23 10:20"},
|
|
{ID: "job-102", Tool: "Bulk assign labels", Scope: "components", Mode: "apply", Status: "running", Owner: "operator", StartedAt: "2026-02-23 10:16"},
|
|
{ID: "job-103", Tool: "Import snapshot preview", Scope: "imports", Mode: "preview", Status: "done", Owner: "operator", StartedAt: "2026-02-23 10:04"},
|
|
{ID: "job-104", Tool: "Repair mappings", Scope: "components", Mode: "apply", Status: "failed", Owner: "operator", StartedAt: "2026-02-23 09:58"},
|
|
{ID: "job-105", Tool: "Refresh timelines", Scope: "maintenance", Mode: "apply", Status: "queued", Owner: "scheduler", StartedAt: "2026-02-23 09:40"},
|
|
{ID: "job-106", Tool: "Export filtered report", Scope: "assets", Mode: "export", Status: "done", Owner: "operator", StartedAt: "2026-02-23 09:22"},
|
|
{ID: "job-107", Tool: "Validate import mapping", Scope: "imports", Mode: "preview", Status: "running", Owner: "operator", StartedAt: "2026-02-23 09:10"},
|
|
{ID: "job-108", Tool: "Cleanup stale drafts", Scope: "maintenance", Mode: "apply", Status: "failed", Owner: "system", StartedAt: "2026-02-23 08:55"},
|
|
}
|
|
}
|
|
|
|
func withQuery(base string, updates map[string]string) string {
|
|
u, err := url.Parse(base)
|
|
if err != nil {
|
|
return base
|
|
}
|
|
q := u.Query()
|
|
for k, v := range updates {
|
|
if v == "" {
|
|
q.Del(k)
|
|
continue
|
|
}
|
|
q.Set(k, v)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
func dict(args ...string) map[string]string {
|
|
out := map[string]string{}
|
|
for i := 0; i+1 < len(args); i += 2 {
|
|
out[args[i]] = args[i+1]
|
|
}
|
|
return out
|
|
}
|